From 53393446303b284ac8052e0a1461ee2f7adeb0bd Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Tue, 16 Aug 2022 06:05:03 -0600 Subject: [PATCH] PS-1133 Feature/mv3 browser observable memory caching (#3245) * Create sessions sync structure * Add observing to session-syncer * Do not run syncer logic in decorator tests * Extract test constants * Change Observables to BehaviorSubject * Move sendMessage to static method in BrowserApi * Implement session sync * only watch in manifest v3 * Use session sync on folder service * Add array observable sync * Bypass cache on update from message * Create feature and dev flags for browser * Protect development-only methods with decorator * Improve todo comments for long-term residency * Use class properties in init * Do not reuse mocks * Use json (de)serialization patterns * Fix failing session storage in dev environment * Split up complex EncString constructor * Default false for decrypted session storage * Try removing hydrate EncString method * PR review * PR test review --- .eslintignore | 3 + apps/browser/config/base.json | 4 + apps/browser/config/config.js | 30 +++ apps/browser/config/development.json | 6 + apps/browser/config/production.json | 3 + .../browser/src/background/main.background.ts | 2 +- apps/browser/src/browser/browserApi.ts | 5 + .../src/decorators/dev-flag.decorator.spec.ts | 35 ++++ .../src/decorators/dev-flag.decorator.ts | 15 ++ .../browser-session.decorator.spec.ts | 64 +++++++ .../browser-session.decorator.ts | 47 +++++ .../session-sync-observable/index.ts | 2 + .../session-storable.ts | 7 + .../session-sync.decorator.spec.ts | 23 +++ .../session-sync.decorator.ts | 51 ++++++ .../session-syncer.spec.ts | 156 ++++++++++++++++ .../session-sync-observable/session-syncer.ts | 79 ++++++++ .../sync-item-metadata.ts | 22 +++ .../synced-item-metadata.spec.ts | 54 ++++++ apps/browser/src/flags.ts | 41 +++++ .../services/abstractions/state.service.ts | 2 + .../src/services/browserMessaging.service.ts | 5 +- .../src/services/folders/folder.service.ts | 15 ++ .../localBackedSessionStorage.service.ts | 52 ++++-- .../src/services/state.service.spec.ts | 140 +++++++++----- apps/browser/src/services/state.service.ts | 11 ++ apps/browser/test.setup.ts | 50 ++--- apps/browser/webpack.config.js | 7 + apps/cli/config/config.js | 1 - .../spec/models/domain/encString.spec.ts | 8 + libs/common/spec/models/domain/folder.spec.ts | 24 +++ .../spec/models/view/folderView.spec.ts | 22 +++ .../src/abstractions/storage.service.ts | 4 + libs/common/src/models/domain/encString.ts | 173 ++++++++++-------- libs/common/src/models/domain/folder.ts | 7 + libs/common/src/models/view/folderView.ts | 7 + .../src/services/folder/folder.service.ts | 4 +- 37 files changed, 1018 insertions(+), 163 deletions(-) create mode 100644 apps/browser/config/base.json create mode 100644 apps/browser/config/config.js create mode 100644 apps/browser/config/development.json create mode 100644 apps/browser/config/production.json create mode 100644 apps/browser/src/decorators/dev-flag.decorator.spec.ts create mode 100644 apps/browser/src/decorators/dev-flag.decorator.ts create mode 100644 apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts create mode 100644 apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts create mode 100644 apps/browser/src/decorators/session-sync-observable/index.ts create mode 100644 apps/browser/src/decorators/session-sync-observable/session-storable.ts create mode 100644 apps/browser/src/decorators/session-sync-observable/session-sync.decorator.spec.ts create mode 100644 apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts create mode 100644 apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts create mode 100644 apps/browser/src/decorators/session-sync-observable/session-syncer.ts create mode 100644 apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts create mode 100644 apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts create mode 100644 apps/browser/src/flags.ts create mode 100644 apps/browser/src/services/folders/folder.service.ts create mode 100644 libs/common/spec/models/view/folderView.spec.ts diff --git a/.eslintignore b/.eslintignore index 4e2b3a58fd..2dff9d9aeb 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,6 +8,7 @@ **/jest.config.js **/gulpfile.js +apps/browser/config/config.js apps/browser/src/content/autofill.js apps/browser/src/scripts/duo.js @@ -18,3 +19,5 @@ apps/web/config.js apps/web/scripts/*.js apps/web/src/theme.js apps/web/tailwind.config.js + +apps/cli/config/config.js diff --git a/apps/browser/config/base.json b/apps/browser/config/base.json new file mode 100644 index 0000000000..6df6c2cfdb --- /dev/null +++ b/apps/browser/config/base.json @@ -0,0 +1,4 @@ +{ + "dev_flags": {}, + "flags": {} +} diff --git a/apps/browser/config/config.js b/apps/browser/config/config.js new file mode 100644 index 0000000000..81e2d619fe --- /dev/null +++ b/apps/browser/config/config.js @@ -0,0 +1,30 @@ +function load(envName) { + return { + ...loadConfig(envName), + ...loadConfig("local"), + }; +} + +function log(configObj) { + const repeatNum = 50; + console.log(`${"=".repeat(repeatNum)}\nenvConfig`); + console.log(JSON.stringify(configObj, null, 2)); + console.log(`${"=".repeat(repeatNum)}`); +} + +function loadConfig(configName) { + try { + return require(`./${configName}.json`); + } catch (e) { + if (e instanceof Error && e.code === "MODULE_NOT_FOUND") { + return {}; + } else { + throw e; + } + } +} + +module.exports = { + load, + log, +}; diff --git a/apps/browser/config/development.json b/apps/browser/config/development.json new file mode 100644 index 0000000000..a91e00ae5c --- /dev/null +++ b/apps/browser/config/development.json @@ -0,0 +1,6 @@ +{ + "devFlags": { + "storeSessionDecrypted": false + }, + "flags": {} +} diff --git a/apps/browser/config/production.json b/apps/browser/config/production.json new file mode 100644 index 0000000000..b04d1531a2 --- /dev/null +++ b/apps/browser/config/production.json @@ -0,0 +1,3 @@ +{ + "flags": {} +} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 3b0f92e821..04f963e186 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -54,7 +54,6 @@ 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 { FolderApiService } from "@bitwarden/common/services/folder/folder-api.service"; -import { FolderService } from "@bitwarden/common/services/folder/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"; @@ -90,6 +89,7 @@ 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 { FolderService } from "../services/folders/folder.service"; import I18nService from "../services/i18n.service"; import { KeyGenerationService } from "../services/keyGeneration.service"; import { LocalBackedSessionStorageService } from "../services/localBackedSessionStorage.service"; diff --git a/apps/browser/src/browser/browserApi.ts b/apps/browser/src/browser/browserApi.ts index b5991be4ce..7b0152a9bd 100644 --- a/apps/browser/src/browser/browserApi.ts +++ b/apps/browser/src/browser/browserApi.ts @@ -115,6 +115,11 @@ export class BrowserApi { ); } + static sendMessage(subscriber: string, arg: any = {}) { + const message = Object.assign({}, { command: subscriber }, arg); + return chrome.runtime.sendMessage(message); + } + static async closeLoginTab() { const tabs = await BrowserApi.tabsQuery({ active: true, diff --git a/apps/browser/src/decorators/dev-flag.decorator.spec.ts b/apps/browser/src/decorators/dev-flag.decorator.spec.ts new file mode 100644 index 0000000000..c5401f8a09 --- /dev/null +++ b/apps/browser/src/decorators/dev-flag.decorator.spec.ts @@ -0,0 +1,35 @@ +import { devFlagEnabled } from "../flags"; + +import { devFlag } from "./dev-flag.decorator"; + +let devFlagEnabledMock: jest.Mock; +jest.mock("../flags", () => ({ + ...jest.requireActual("../flags"), + devFlagEnabled: jest.fn(), +})); + +class TestClass { + @devFlag("storeSessionDecrypted") test() { + return "test"; + } +} + +describe("devFlag decorator", () => { + beforeEach(() => { + devFlagEnabledMock = devFlagEnabled as jest.Mock; + }); + + it("should throw an error if the dev flag is disabled", () => { + devFlagEnabledMock.mockReturnValue(false); + expect(() => { + new TestClass().test(); + }).toThrowError("This method should not be called, it is protected by a disabled dev flag."); + }); + + it("should not throw an error if the dev flag is enabled", () => { + devFlagEnabledMock.mockReturnValue(true); + expect(() => { + new TestClass().test(); + }).not.toThrowError(); + }); +}); diff --git a/apps/browser/src/decorators/dev-flag.decorator.ts b/apps/browser/src/decorators/dev-flag.decorator.ts new file mode 100644 index 0000000000..da0c993446 --- /dev/null +++ b/apps/browser/src/decorators/dev-flag.decorator.ts @@ -0,0 +1,15 @@ +import { devFlagEnabled, DevFlagName } from "../flags"; + +export function devFlag(flag: DevFlagName) { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + descriptor.value = function (...args: any[]) { + if (!devFlagEnabled(flag)) { + throw new Error( + `This method should not be called, it is protected by a disabled dev flag.` + ); + } + return originalMethod.apply(this, args); + }; + }; +} diff --git a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts b/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts new file mode 100644 index 0000000000..b48658efa3 --- /dev/null +++ b/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts @@ -0,0 +1,64 @@ +import { BehaviorSubject } from "rxjs"; + +import { StateService } from "../../services/state.service"; + +import { browserSession } from "./browser-session.decorator"; +import { SessionStorable } from "./session-storable"; +import { sessionSync } from "./session-sync.decorator"; + +// browserSession initializes SessionSyncers for each sessionSync decorated property +// We don't want to test SessionSyncers, so we'll mock them +jest.mock("./session-syncer"); + +describe("browserSession decorator", () => { + it("should throw if StateService is not a constructor argument", () => { + @browserSession + class TestClass {} + expect(() => { + new TestClass(); + }).toThrowError( + "Cannot decorate TestClass with browserSession, Browser's StateService must be injected" + ); + }); + + it("should create if StateService is a constructor argument", () => { + const stateService = Object.create(StateService.prototype, {}); + + @browserSession + class TestClass { + constructor(private stateService: StateService) {} + } + + expect(new TestClass(stateService)).toBeDefined(); + }); + + describe("interaction with @sessionSync decorator", () => { + let stateService: StateService; + + @browserSession + class TestClass { + @sessionSync({ initializer: (s: string) => s }) + behaviorSubject = new BehaviorSubject(""); + + constructor(private stateService: StateService) {} + + fromJSON(json: any) { + this.behaviorSubject.next(json); + } + } + + beforeEach(() => { + stateService = Object.create(StateService.prototype, {}) as StateService; + }); + + it("should create a session syncer", () => { + const testClass = new TestClass(stateService) as any as SessionStorable; + expect(testClass.__sessionSyncers.length).toEqual(1); + }); + + it("should initialize the session syncer", () => { + const testClass = new TestClass(stateService) as any as SessionStorable; + expect(testClass.__sessionSyncers[0].init).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts b/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts new file mode 100644 index 0000000000..bee93173d1 --- /dev/null +++ b/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts @@ -0,0 +1,47 @@ +import { Constructor } from "type-fest"; + +import { StateService } from "../../services/state.service"; + +import { SessionStorable } from "./session-storable"; +import { SessionSyncer } from "./session-syncer"; +import { SyncedItemMetadata } from "./sync-item-metadata"; + +/** + * Mark the class as syncing state across the browser session. This decorator finds rxjs BehaviorSubject properties + * marked with @sessionSync and syncs these values across the browser session. + * + * @param constructor + * @returns A new constructor that extends the original one to add session syncing. + */ +export function browserSession>(constructor: TCtor) { + return class extends constructor implements SessionStorable { + __syncedItemMetadata: SyncedItemMetadata[]; + __sessionSyncers: SessionSyncer[]; + + constructor(...args: any[]) { + super(...args); + + // Require state service to be injected + const stateService = args.find((arg) => arg instanceof StateService); + if (!stateService) { + throw new Error( + `Cannot decorate ${constructor.name} with browserSession, Browser's StateService must be injected` + ); + } + + if (this.__syncedItemMetadata == null || !(this.__syncedItemMetadata instanceof Array)) { + return; + } + + this.__sessionSyncers = this.__syncedItemMetadata.map((metadata) => + this.buildSyncer(metadata, stateService) + ); + } + + buildSyncer(metadata: SyncedItemMetadata, stateService: StateService) { + const syncer = new SessionSyncer((this as any)[metadata.key], stateService, metadata); + syncer.init(); + return syncer; + } + }; +} diff --git a/apps/browser/src/decorators/session-sync-observable/index.ts b/apps/browser/src/decorators/session-sync-observable/index.ts new file mode 100644 index 0000000000..c0c547192e --- /dev/null +++ b/apps/browser/src/decorators/session-sync-observable/index.ts @@ -0,0 +1,2 @@ +export { browserSession } from "./browser-session.decorator"; +export { sessionSync } from "./session-sync.decorator"; diff --git a/apps/browser/src/decorators/session-sync-observable/session-storable.ts b/apps/browser/src/decorators/session-sync-observable/session-storable.ts new file mode 100644 index 0000000000..f5838b86ef --- /dev/null +++ b/apps/browser/src/decorators/session-sync-observable/session-storable.ts @@ -0,0 +1,7 @@ +import { SessionSyncer } from "./session-syncer"; +import { SyncedItemMetadata } from "./sync-item-metadata"; + +export interface SessionStorable { + __syncedItemMetadata: SyncedItemMetadata[]; + __sessionSyncers: SessionSyncer[]; +} diff --git a/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.spec.ts b/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.spec.ts new file mode 100644 index 0000000000..d1cb8e7d15 --- /dev/null +++ b/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.spec.ts @@ -0,0 +1,23 @@ +import { BehaviorSubject } from "rxjs"; + +import { sessionSync } from "./session-sync.decorator"; + +describe("sessionSync decorator", () => { + const initializer = (s: string) => "test"; + const ctor = String; + class TestClass { + @sessionSync({ ctor: ctor, initializer: initializer }) + testProperty = new BehaviorSubject(""); + } + + it("should add __syncedItemKeys to prototype", () => { + const testClass = new TestClass(); + expect((testClass as any).__syncedItemMetadata).toEqual([ + expect.objectContaining({ + key: "TestClass_testProperty", + ctor: ctor, + initializer: initializer, + }), + ]); + }); +}); diff --git a/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts b/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts new file mode 100644 index 0000000000..e9887c0085 --- /dev/null +++ b/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts @@ -0,0 +1,51 @@ +import { Jsonify } from "type-fest"; + +import { SessionStorable } from "./session-storable"; + +class BuildOptions { + ctor?: new () => T; + initializer?: (keyValuePair: Jsonify) => T; + initializeAsArray? = false; +} + +/** + * A decorator used to indicate the BehaviorSubject should be synced for this browser session across all contexts. + * + * >**Note** This decorator does nothing if the enclosing class is not decorated with @browserSession. + * + * >**Note** The Behavior subject must be initialized with a default or in the constructor of the class. If it is not, an error will be thrown. + * + * >**!!Warning!!** If the property is overwritten at any time, the new value will not be synced across the browser session. + * + * @param buildOptions + * Builders for the value, requires either a constructor (ctor) for your BehaviorSubject type or an + * initializer function that takes a key value pair representation of the BehaviorSubject data + * and returns your instantiated BehaviorSubject value. `initializeAsArray can optionally be used to indicate + * the provided initializer function should be used to build an array of values. For example, + * ```ts + * \@sessionSync({ initializer: Foo.fromJSON, initializeAsArray: true }) + * ``` + * is equivalent to + * ``` + * \@sessionSync({ initializer: (obj: any[]) => obj.map((f) => Foo.fromJSON }) + * ``` + * + * @returns decorator function + */ +export function sessionSync(buildOptions: BuildOptions) { + return (prototype: unknown, propertyKey: string) => { + // Force prototype into SessionStorable and implement it. + const p = prototype as SessionStorable; + + if (p.__syncedItemMetadata == null) { + p.__syncedItemMetadata = []; + } + + p.__syncedItemMetadata.push({ + key: `${prototype.constructor.name}_${propertyKey}`, + ctor: buildOptions.ctor, + initializer: buildOptions.initializer, + initializeAsArray: buildOptions.initializeAsArray, + }); + }; +} diff --git a/apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts b/apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts new file mode 100644 index 0000000000..b08ee85647 --- /dev/null +++ b/apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts @@ -0,0 +1,156 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { BrowserApi } from "../../browser/browserApi"; +import { StateService } from "../../services/abstractions/state.service"; + +import { SessionSyncer } from "./session-syncer"; + +describe("session syncer", () => { + const key = "Test__behaviorSubject"; + const metaData = { key, initializer: (s: string) => s }; + let stateService: MockProxy; + let sut: SessionSyncer; + let behaviorSubject: BehaviorSubject; + + beforeEach(() => { + behaviorSubject = new BehaviorSubject(""); + jest.spyOn(chrome.runtime, "getManifest").mockReturnValue({ + name: "bitwarden-test", + version: "0.0.0", + manifest_version: 3, + }); + + stateService = mock(); + sut = new SessionSyncer(behaviorSubject, stateService, metaData); + }); + + afterEach(() => { + jest.resetAllMocks(); + + behaviorSubject.unsubscribe(); + }); + + describe("constructor", () => { + it("should throw if behaviorSubject is not an instance of BehaviorSubject", () => { + expect(() => { + new SessionSyncer({} as any, stateService, null); + }).toThrowError("behaviorSubject must be an instance of BehaviorSubject"); + }); + + it("should create if either ctor or initializer is provided", () => { + expect( + new SessionSyncer(behaviorSubject, stateService, { key: key, ctor: String }) + ).toBeDefined(); + expect( + new SessionSyncer(behaviorSubject, stateService, { + key: key, + initializer: (s: any) => s, + }) + ).toBeDefined(); + }); + it("should throw if neither ctor or initializer is provided", () => { + expect(() => { + new SessionSyncer(behaviorSubject, stateService, { key: key }); + }).toThrowError("ctor or initializer must be provided"); + }); + }); + + describe("manifest v2 init", () => { + let observeSpy: jest.SpyInstance; + let listenForUpdatesSpy: jest.SpyInstance; + + beforeEach(() => { + observeSpy = jest.spyOn(behaviorSubject, "subscribe").mockReturnThis(); + listenForUpdatesSpy = jest.spyOn(BrowserApi, "messageListener").mockReturnValue(); + jest.spyOn(chrome.runtime, "getManifest").mockReturnValue({ + name: "bitwarden-test", + version: "0.0.0", + manifest_version: 2, + }); + + sut.init(); + }); + + it("should not start observing", () => { + expect(observeSpy).not.toHaveBeenCalled(); + }); + + it("should not start listening", () => { + expect(listenForUpdatesSpy).not.toHaveBeenCalled(); + }); + }); + + describe("a value is emitted on the observable", () => { + let sendMessageSpy: jest.SpyInstance; + + beforeEach(() => { + sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage"); + + sut.init(); + + behaviorSubject.next("test"); + }); + + it("should update the session memory", async () => { + // await finishing of fire-and-forget operation + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(stateService.setInSessionMemory).toHaveBeenCalledTimes(1); + expect(stateService.setInSessionMemory).toHaveBeenCalledWith(key, "test"); + }); + + it("should update sessionSyncers in other contexts", async () => { + // await finishing of fire-and-forget operation + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(sendMessageSpy).toHaveBeenCalledWith(`${key}_update`, { id: sut.id }); + }); + }); + + describe("A message is received", () => { + let nextSpy: jest.SpyInstance; + let sendMessageSpy: jest.SpyInstance; + + beforeEach(() => { + nextSpy = jest.spyOn(behaviorSubject, "next"); + sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage"); + + sut.init(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should ignore messages with the wrong command", async () => { + await sut.updateFromMessage({ command: "wrong_command", id: sut.id }); + + expect(stateService.getFromSessionMemory).not.toHaveBeenCalled(); + expect(nextSpy).not.toHaveBeenCalled(); + }); + + it("should ignore messages from itself", async () => { + await sut.updateFromMessage({ command: `${key}_update`, id: sut.id }); + + expect(stateService.getFromSessionMemory).not.toHaveBeenCalled(); + expect(nextSpy).not.toHaveBeenCalled(); + }); + + it("should update from message on emit from another instance", async () => { + stateService.getFromSessionMemory.mockResolvedValue("test"); + + await sut.updateFromMessage({ command: `${key}_update`, id: "different_id" }); + + expect(stateService.getFromSessionMemory).toHaveBeenCalledTimes(1); + expect(stateService.getFromSessionMemory).toHaveBeenCalledWith(key); + + expect(nextSpy).toHaveBeenCalledTimes(1); + expect(nextSpy).toHaveBeenCalledWith("test"); + expect(behaviorSubject.value).toBe("test"); + + // Expect no circular messaging + expect(sendMessageSpy).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/apps/browser/src/decorators/session-sync-observable/session-syncer.ts b/apps/browser/src/decorators/session-sync-observable/session-syncer.ts new file mode 100644 index 0000000000..0bfc56d521 --- /dev/null +++ b/apps/browser/src/decorators/session-sync-observable/session-syncer.ts @@ -0,0 +1,79 @@ +import { BehaviorSubject, Subscription } from "rxjs"; + +import { Utils } from "@bitwarden/common/misc/utils"; + +import { BrowserApi } from "../../browser/browserApi"; +import { StateService } from "../../services/abstractions/state.service"; + +import { SyncedItemMetadata } from "./sync-item-metadata"; + +export class SessionSyncer { + subscription: Subscription; + id = Utils.newGuid(); + + // everyone gets the same initial values + private ignoreNextUpdate = true; + + constructor( + private behaviorSubject: BehaviorSubject, + private stateService: StateService, + private metaData: SyncedItemMetadata + ) { + if (!(behaviorSubject instanceof BehaviorSubject)) { + throw new Error("behaviorSubject must be an instance of BehaviorSubject"); + } + + if (metaData.ctor == null && metaData.initializer == null) { + throw new Error("ctor or initializer must be provided"); + } + } + + init() { + if (chrome.runtime.getManifest().manifest_version != 3) { + return; + } + + this.observe(); + this.listenForUpdates(); + } + + private observe() { + // This may be a memory leak. + // There is no good time to unsubscribe from this observable. Hopefully Manifest V3 clears memory from temporary + // contexts. If so, this is handled by destruction of the context. + this.subscription = this.behaviorSubject.subscribe(async (next) => { + if (this.ignoreNextUpdate) { + this.ignoreNextUpdate = false; + return; + } + await this.updateSession(next); + }); + } + + private listenForUpdates() { + // This is an unawaited promise, but it will be executed asynchronously in the background. + BrowserApi.messageListener( + this.updateMessageCommand, + async (message) => await this.updateFromMessage(message) + ); + } + + async updateFromMessage(message: any) { + if (message.command != this.updateMessageCommand || message.id === this.id) { + return; + } + const keyValuePair = await this.stateService.getFromSessionMemory(this.metaData.key); + const value = SyncedItemMetadata.buildFromKeyValuePair(keyValuePair, this.metaData); + this.ignoreNextUpdate = true; + this.behaviorSubject.next(value); + } + + private async updateSession(value: any) { + await this.stateService.setInSessionMemory(this.metaData.key, value); + await BrowserApi.sendMessage(this.updateMessageCommand, { id: this.id }); + } + + private get updateMessageCommand() { + return `${this.metaData.key}_update`; + } +} diff --git a/apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts b/apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts new file mode 100644 index 0000000000..7f632c962e --- /dev/null +++ b/apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts @@ -0,0 +1,22 @@ +export class SyncedItemMetadata { + key: string; + ctor?: new () => any; + initializer?: (keyValuePair: any) => any; + initializeAsArray?: boolean; + + static buildFromKeyValuePair(keyValuePair: any, metadata: SyncedItemMetadata): any { + const builder = SyncedItemMetadata.getBuilder(metadata); + + if (metadata.initializeAsArray) { + return keyValuePair.map((o: any) => builder(o)); + } else { + return builder(keyValuePair); + } + } + + private static getBuilder(metadata: SyncedItemMetadata): (o: any) => any { + return metadata.initializer != null + ? metadata.initializer + : (o: any) => Object.assign(new metadata.ctor(), o); + } +} diff --git a/apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts b/apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts new file mode 100644 index 0000000000..c8e819dbe6 --- /dev/null +++ b/apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts @@ -0,0 +1,54 @@ +import { SyncedItemMetadata } from "./sync-item-metadata"; + +describe("build from key value pair", () => { + const key = "key"; + const initializer = (s: any) => "used initializer"; + class TestClass {} + const ctor = TestClass; + + it("should call initializer if provided", () => { + const actual = SyncedItemMetadata.buildFromKeyValuePair( + {}, + { + key: "key", + initializer: initializer, + } + ); + + expect(actual).toEqual("used initializer"); + }); + + it("should call ctor if provided", () => { + const expected = { provided: "value" }; + const actual = SyncedItemMetadata.buildFromKeyValuePair(expected, { + key: key, + ctor: ctor, + }); + + expect(actual).toBeInstanceOf(ctor); + expect(actual).toEqual(expect.objectContaining(expected)); + }); + + it("should prefer using initializer if both are provided", () => { + const actual = SyncedItemMetadata.buildFromKeyValuePair( + {}, + { + key: key, + initializer: initializer, + ctor: ctor, + } + ); + + expect(actual).toEqual("used initializer"); + }); + + it("should honor initialize as array", () => { + const actual = SyncedItemMetadata.buildFromKeyValuePair([1, 2], { + key: key, + initializer: initializer, + initializeAsArray: true, + }); + + expect(actual).toEqual(["used initializer", "used initializer"]); + }); +}); diff --git a/apps/browser/src/flags.ts b/apps/browser/src/flags.ts new file mode 100644 index 0000000000..2480c2cace --- /dev/null +++ b/apps/browser/src/flags.ts @@ -0,0 +1,41 @@ +function getFlags(envFlags: string | T): T { + if (typeof envFlags === "string") { + return JSON.parse(envFlags) as T; + } else { + return envFlags as T; + } +} + +/* Placeholder for when we have a relevant feature flag +export type Flags = { test?: boolean }; +export type FlagName = keyof Flags; +export function flagEnabled(flag: FlagName): boolean { + const flags = getFlags(process.env.FLAGS); + return flags[flag] == null || flags[flag]; +} +*/ + +/** + * These flags are useful for development and testing. + * Dev Flags are always OFF in production. + */ +export type DevFlags = { + storeSessionDecrypted?: boolean; +}; + +export type DevFlagName = keyof DevFlags; + +/** + * Gets the value of a dev flag from environment. + * Will always return false unless in development. + * @param flag The name of the dev flag to check + * @returns The value of the flag + */ +export function devFlagEnabled(flag: DevFlagName): boolean { + if (process.env.ENV !== "development") { + return false; + } + + const devFlags = getFlags(process.env.DEV_FLAGS); + return devFlags[flag] == null || devFlags[flag]; +} diff --git a/apps/browser/src/services/abstractions/state.service.ts b/apps/browser/src/services/abstractions/state.service.ts index 1ed5ae24a2..ca2a5ced7f 100644 --- a/apps/browser/src/services/abstractions/state.service.ts +++ b/apps/browser/src/services/abstractions/state.service.ts @@ -7,6 +7,8 @@ import { BrowserGroupingsComponentState } from "src/models/browserGroupingsCompo import { BrowserSendComponentState } from "src/models/browserSendComponentState"; export abstract class StateService extends BaseStateServiceAbstraction { + abstract getFromSessionMemory(key: string): Promise; + abstract setInSessionMemory(key: string, value: any): Promise; getBrowserGroupingComponentState: ( options?: StorageOptions ) => Promise; diff --git a/apps/browser/src/services/browserMessaging.service.ts b/apps/browser/src/services/browserMessaging.service.ts index ae121793a0..7832e08b32 100644 --- a/apps/browser/src/services/browserMessaging.service.ts +++ b/apps/browser/src/services/browserMessaging.service.ts @@ -1,8 +1,9 @@ import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; +import { BrowserApi } from "../browser/browserApi"; + export default class BrowserMessagingService implements MessagingService { send(subscriber: string, arg: any = {}) { - const message = Object.assign({}, { command: subscriber }, arg); - chrome.runtime.sendMessage(message); + return BrowserApi.sendMessage(subscriber, arg); } } diff --git a/apps/browser/src/services/folders/folder.service.ts b/apps/browser/src/services/folders/folder.service.ts new file mode 100644 index 0000000000..f97b9d0d0b --- /dev/null +++ b/apps/browser/src/services/folders/folder.service.ts @@ -0,0 +1,15 @@ +import { BehaviorSubject } from "rxjs/internal/BehaviorSubject"; + +import { Folder } from "@bitwarden/common/models/domain/folder"; +import { FolderView } from "@bitwarden/common/models/view/folderView"; +import { FolderService as BaseFolderService } from "@bitwarden/common/services/folder/folder.service"; + +import { browserSession, sessionSync } from "../../decorators/session-sync-observable"; + +@browserSession +export class FolderService extends BaseFolderService { + @sessionSync({ initializer: Folder.fromJSON, initializeAsArray: true }) + protected _folders: BehaviorSubject; + @sessionSync({ initializer: FolderView.fromJSON, initializeAsArray: true }) + protected _folderViews: BehaviorSubject; +} diff --git a/apps/browser/src/services/localBackedSessionStorage.service.ts b/apps/browser/src/services/localBackedSessionStorage.service.ts index 0f5305366a..cdc7d4cf15 100644 --- a/apps/browser/src/services/localBackedSessionStorage.service.ts +++ b/apps/browser/src/services/localBackedSessionStorage.service.ts @@ -1,8 +1,11 @@ import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service"; -import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractCachedStorageService } from "@bitwarden/common/abstractions/storage.service"; import { EncString } from "@bitwarden/common/models/domain/encString"; import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; +import { devFlag } from "../decorators/dev-flag.decorator"; +import { devFlagEnabled } from "../flags"; + import { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service"; import BrowserLocalStorageService from "./browserLocalStorage.service"; import BrowserMemoryStorageService from "./browserMemoryStorage.service"; @@ -12,8 +15,8 @@ const keys = { sessionKey: "session", }; -export class LocalBackedSessionStorageService extends AbstractStorageService { - private cache = new Map(); +export class LocalBackedSessionStorageService extends AbstractCachedStorageService { + private cache = new Map(); private localStorage = new BrowserLocalStorageService(); private sessionStorage = new BrowserMemoryStorageService(); @@ -26,23 +29,27 @@ export class LocalBackedSessionStorageService extends AbstractStorageService { async get(key: string): Promise { if (this.cache.has(key)) { - return this.cache.get(key); + return this.cache.get(key) as T; } + return await this.getBypassCache(key); + } + + async getBypassCache(key: string): Promise { 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); + return this.cache.get(key) as T; } async has(key: string): Promise { return (await this.get(key)) != null; } - async save(key: string, obj: any): Promise { + async save(key: string, obj: T): Promise { if (obj == null) { this.cache.delete(key); } else { @@ -59,13 +66,17 @@ export class LocalBackedSessionStorageService extends AbstractStorageService { await this.save(key, null); } - async getLocalSession(encKey: SymmetricCryptoKey): Promise { + async getLocalSession(encKey: SymmetricCryptoKey): Promise> { const local = await this.localStorage.get(keys.sessionKey); if (local == null) { return null; } + if (devFlagEnabled("storeSessionDecrypted")) { + return local as any as Record; + } + 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 @@ -76,7 +87,26 @@ export class LocalBackedSessionStorageService extends AbstractStorageService { return JSON.parse(sessionJson); } - async setLocalSession(session: any, key: SymmetricCryptoKey) { + async setLocalSession(session: Record, key: SymmetricCryptoKey) { + if (devFlagEnabled("storeSessionDecrypted")) { + await this.setDecryptedLocalSession(session); + } else { + await this.setEncryptedLocalSession(session, key); + } + } + + @devFlag("storeSessionDecrypted") + async setDecryptedLocalSession(session: Record): Promise { + // Make sure we're storing the jsonified version of the session + const jsonSession = JSON.parse(JSON.stringify(session)); + if (session == null) { + await this.localStorage.remove(keys.sessionKey); + } else { + await this.localStorage.save(keys.sessionKey, jsonSession); + } + } + + async setEncryptedLocalSession(session: Record, key: SymmetricCryptoKey) { const jsonSession = JSON.stringify(session); const encSession = await this.encryptService.encrypt(jsonSession, key); @@ -87,14 +117,12 @@ export class LocalBackedSessionStorageService extends AbstractStorageService { } async getSessionEncKey(): Promise { - let storedKey = (await this.sessionStorage.get(keys.encKey)) as SymmetricCryptoKey; + let storedKey = await this.sessionStorage.get(keys.encKey); if (storedKey == null || Object.keys(storedKey).length == 0) { storedKey = await this.keyGenerationService.makeEphemeralKey(); await this.setSessionEncKey(storedKey); } - return SymmetricCryptoKey.fromJSON( - Object.create(SymmetricCryptoKey.prototype, Object.getOwnPropertyDescriptors(storedKey)) - ); + return SymmetricCryptoKey.fromJSON(storedKey); } async setSessionEncKey(input: SymmetricCryptoKey): Promise { diff --git a/apps/browser/src/services/state.service.spec.ts b/apps/browser/src/services/state.service.spec.ts index 2fe6a57859..60813c2293 100644 --- a/apps/browser/src/services/state.service.spec.ts +++ b/apps/browser/src/services/state.service.spec.ts @@ -1,7 +1,10 @@ import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; +import { + AbstractCachedStorageService, + 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"; @@ -14,12 +17,12 @@ import { BrowserComponentState } from "../models/browserComponentState"; import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../models/browserSendComponentState"; +import { LocalBackedSessionStorageService } from "./localBackedSessionStorage.service"; 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>; @@ -33,7 +36,6 @@ describe("Browser State Service", () => { beforeEach(() => { secureStorageService = Substitute.for(); diskStorageService = Substitute.for(); - memoryStorageService = Substitute.for(); logService = Substitute.for(); stateMigrationService = Substitute.for(); stateFactory = Substitute.for(); @@ -44,66 +46,104 @@ describe("Browser State Service", () => { 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(); + describe("direct memory storage access", () => { + let memoryStorageService: AbstractCachedStorageService; - const actual = await sut.getBrowserGroupingComponentState(); - expect(actual).toBeInstanceOf(BrowserGroupingsComponentState); + beforeEach(() => { + // We need `AbstractCachedStorageService` in the prototype chain to correctly test cache bypass. + memoryStorageService = Object.create(LocalBackedSessionStorageService.prototype); + + sut = new StateService( + diskStorageService, + secureStorageService, + memoryStorageService, + logService, + stateMigrationService, + stateFactory, + useAccountCache + ); + }); + + it("should bypass cache if possible", async () => { + const spyBypass = jest + .spyOn(memoryStorageService, "getBypassCache") + .mockResolvedValue("value"); + const spyGet = jest.spyOn(memoryStorageService, "get"); + const result = await sut.getFromSessionMemory("key"); + expect(spyBypass).toHaveBeenCalled(); + expect(spyGet).not.toHaveBeenCalled(); + expect(result).toBe("value"); }); }); - describe("getBrowserCipherComponentState", () => { - it("should return a BrowserComponentState", async () => { - const componentState = new BrowserComponentState(); - componentState.scrollY = 0; - componentState.searchText = "test"; - state.accounts[userId].ciphers = componentState; + describe("state methods", () => { + let memoryStorageService: SubstituteOf; - const actual = await sut.getBrowserCipherComponentState(); - expect(actual).toStrictEqual(componentState); + beforeEach(() => { + memoryStorageService = Substitute.for(); + 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("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; + describe("getBrowserGroupingComponentState", () => { + it("should return a BrowserGroupingsComponentState", async () => { + state.accounts[userId].groupings = new BrowserGroupingsComponentState(); - const actual = await sut.getBrowserSendComponentState(); - expect(actual).toBeInstanceOf(BrowserSendComponentState); - expect(actual).toMatchObject(sendState); + const actual = await sut.getBrowserGroupingComponentState(); + expect(actual).toBeInstanceOf(BrowserGroupingsComponentState); + }); }); - }); - describe("getBrowserSendTypeComponentState", () => { - it("should return a BrowserComponentState", async () => { - const componentState = new BrowserComponentState(); - componentState.scrollY = 0; - componentState.searchText = "test"; - state.accounts[userId].sendType = componentState; + 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.getBrowserSendTypeComponentState(); - expect(actual).toStrictEqual(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 0153be848d..6685f495e0 100644 --- a/apps/browser/src/services/state.service.ts +++ b/apps/browser/src/services/state.service.ts @@ -1,3 +1,4 @@ +import { AbstractCachedStorageService } from "@bitwarden/common/abstractions/storage.service"; import { GlobalState } from "@bitwarden/common/models/domain/globalState"; import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions"; import { @@ -16,6 +17,16 @@ export class StateService extends BaseStateService implements StateServiceAbstraction { + async getFromSessionMemory(key: string): Promise { + return this.memoryStorageService instanceof AbstractCachedStorageService + ? await this.memoryStorageService.getBypassCache(key) + : await this.memoryStorageService.get(key); + } + + async setInSessionMemory(key: string, value: any): Promise { + await this.memoryStorageService.save(key, value); + } + async addAccount(account: Account) { // Apply browser overrides to default account values account = new Account(account); diff --git a/apps/browser/test.setup.ts b/apps/browser/test.setup.ts index 6215bd65f1..a231353f64 100644 --- a/apps/browser/test.setup.ts +++ b/apps/browser/test.setup.ts @@ -1,26 +1,32 @@ // 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, - }, +const storage = { + local: { + set: jest.fn(), + get: jest.fn(), + remove: jest.fn(), + QUOTA_BYTES, + getBytesInUse: jest.fn(), + clear: jest.fn(), }, + session: { + set: jest.fn(), + get: jest.fn(), + has: jest.fn(), + remove: jest.fn(), + }, +}; + +const runtime = { + onMessage: { + addListener: jest.fn(), + }, + sendMessage: jest.fn(), + getManifest: jest.fn(), +}; + +// set chrome +global.chrome = { + storage, + runtime, } as any; diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 3f16579e44..301e20a2c2 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -6,6 +6,7 @@ const CopyWebpackPlugin = require("copy-webpack-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const { AngularWebpackPlugin } = require("@ngtools/webpack"); const TerserPlugin = require("terser-webpack-plugin"); +const configurator = require("./config/config"); if (process.env.NODE_ENV == null) { process.env.NODE_ENV = "development"; @@ -14,6 +15,8 @@ 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 envConfig = configurator.load(ENV); +configurator.log(envConfig); const moduleRules = [ { @@ -116,6 +119,10 @@ const plugins = [ exclude: [/content\/.*/, /notification\/.*/], filename: "[file].map", }), + new webpack.EnvironmentPlugin({ + FLAGS: envConfig.flags, + DEV_FLAGS: ENV === "development" ? envConfig.devFlags : {}, + }), ]; const config = { diff --git a/apps/cli/config/config.js b/apps/cli/config/config.js index 2b2516e7c2..81e2d619fe 100644 --- a/apps/cli/config/config.js +++ b/apps/cli/config/config.js @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ function load(envName) { return { ...loadConfig(envName), diff --git a/libs/common/spec/models/domain/encString.spec.ts b/libs/common/spec/models/domain/encString.spec.ts index 1b0c3d9eb0..aa59d64574 100644 --- a/libs/common/spec/models/domain/encString.spec.ts +++ b/libs/common/spec/models/domain/encString.spec.ts @@ -192,4 +192,12 @@ describe("EncString", () => { cryptoService.received().decryptToUtf8(encString, key); }); }); + + describe("toJSON", () => { + it("Should be represented by the encrypted string", () => { + const encString = new EncString(EncryptionType.AesCbc256_B64, "data", "iv"); + + expect(encString.toJSON()).toBe(encString.encryptedString); + }); + }); }); diff --git a/libs/common/spec/models/domain/folder.spec.ts b/libs/common/spec/models/domain/folder.spec.ts index c36caa80ba..7ae36f9b39 100644 --- a/libs/common/spec/models/domain/folder.spec.ts +++ b/libs/common/spec/models/domain/folder.spec.ts @@ -1,4 +1,5 @@ import { FolderData } from "@bitwarden/common/models/data/folderData"; +import { EncString } from "@bitwarden/common/models/domain/encString"; import { Folder } from "@bitwarden/common/models/domain/folder"; import { mockEnc } from "../../utils"; @@ -38,4 +39,27 @@ describe("Folder", () => { revisionDate: new Date("2022-01-31T12:00:00.000Z"), }); }); + + describe("fromJSON", () => { + jest.mock("@bitwarden/common/models/domain/encString"); + const mockFromJson = (stub: any) => (stub + "_fromJSON") as any; + jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson); + + it("initializes nested objects", () => { + const revisionDate = new Date("2022-08-04T01:06:40.441Z"); + const actual = Folder.fromJSON({ + revisionDate: revisionDate.toISOString(), + name: "name", + id: "id", + }); + + const expected = { + revisionDate: revisionDate, + name: "name_fromJSON", + id: "id", + }; + + expect(actual).toMatchObject(expected); + }); + }); }); diff --git a/libs/common/spec/models/view/folderView.spec.ts b/libs/common/spec/models/view/folderView.spec.ts new file mode 100644 index 0000000000..50b8c2948d --- /dev/null +++ b/libs/common/spec/models/view/folderView.spec.ts @@ -0,0 +1,22 @@ +import { FolderView } from "@bitwarden/common/models/view/folderView"; + +describe("FolderView", () => { + describe("fromJSON", () => { + it("initializes nested objects", () => { + const revisionDate = new Date("2022-08-04T01:06:40.441Z"); + const actual = FolderView.fromJSON({ + revisionDate: revisionDate.toISOString(), + name: "name", + id: "id", + }); + + const expected = { + revisionDate: revisionDate, + name: "name", + id: "id", + }; + + expect(actual).toMatchObject(expected); + }); + }); +}); diff --git a/libs/common/src/abstractions/storage.service.ts b/libs/common/src/abstractions/storage.service.ts index 31fe14ddcf..5cff9fdac6 100644 --- a/libs/common/src/abstractions/storage.service.ts +++ b/libs/common/src/abstractions/storage.service.ts @@ -6,3 +6,7 @@ export abstract class AbstractStorageService { abstract save(key: string, obj: T, options?: StorageOptions): Promise; abstract remove(key: string, options?: StorageOptions): Promise; } + +export abstract class AbstractCachedStorageService extends AbstractStorageService { + abstract getBypassCache(key: string, options?: StorageOptions): Promise; +} diff --git a/libs/common/src/models/domain/encString.ts b/libs/common/src/models/domain/encString.ts index a11ba3a58c..46c267bb04 100644 --- a/libs/common/src/models/domain/encString.ts +++ b/libs/common/src/models/domain/encString.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { IEncrypted } from "@bitwarden/common/interfaces/IEncrypted"; import { CryptoService } from "../../abstractions/crypto.service"; @@ -21,80 +23,9 @@ export class EncString implements IEncrypted { mac?: string ) { if (data != null) { - // data and header - const encType = encryptedStringOrType as EncryptionType; - - if (iv != null) { - this.encryptedString = encType + "." + iv + "|" + data; - } else { - this.encryptedString = encType + "." + data; - } - - // mac - if (mac != null) { - this.encryptedString += "|" + mac; - } - - this.encryptionType = encType; - this.data = data; - this.iv = iv; - this.mac = mac; - - return; - } - - this.encryptedString = encryptedStringOrType as string; - if (!this.encryptedString) { - return; - } - - const headerPieces = this.encryptedString.split("."); - let encPieces: string[] = null; - - if (headerPieces.length === 2) { - try { - this.encryptionType = parseInt(headerPieces[0], null); - encPieces = headerPieces[1].split("|"); - } catch (e) { - return; - } + this.initFromData(encryptedStringOrType as EncryptionType, data, iv, mac); } else { - encPieces = this.encryptedString.split("|"); - this.encryptionType = - encPieces.length === 3 - ? EncryptionType.AesCbc128_HmacSha256_B64 - : EncryptionType.AesCbc256_B64; - } - - switch (this.encryptionType) { - case EncryptionType.AesCbc128_HmacSha256_B64: - case EncryptionType.AesCbc256_HmacSha256_B64: - if (encPieces.length !== 3) { - return; - } - - this.iv = encPieces[0]; - this.data = encPieces[1]; - this.mac = encPieces[2]; - break; - case EncryptionType.AesCbc256_B64: - if (encPieces.length !== 2) { - return; - } - - this.iv = encPieces[0]; - this.data = encPieces[1]; - break; - case EncryptionType.Rsa2048_OaepSha256_B64: - case EncryptionType.Rsa2048_OaepSha1_B64: - if (encPieces.length !== 1) { - return; - } - - this.data = encPieces[0]; - break; - default: - return; + this.initFromEncryptedString(encryptedStringOrType as string); } } @@ -133,4 +64,100 @@ export class EncString implements IEncrypted { get dataBytes(): ArrayBuffer { return this.data == null ? null : Utils.fromB64ToArray(this.data).buffer; } + + toJSON() { + return this.encryptedString; + } + + static fromJSON(obj: Jsonify): EncString { + return new EncString(obj); + } + + private initFromData(encType: EncryptionType, data: string, iv: string, mac: string) { + if (iv != null) { + this.encryptedString = encType + "." + iv + "|" + data; + } else { + this.encryptedString = encType + "." + data; + } + + // mac + if (mac != null) { + this.encryptedString += "|" + mac; + } + + this.encryptionType = encType; + this.data = data; + this.iv = iv; + this.mac = mac; + } + + private initFromEncryptedString(encryptedString: string) { + this.encryptedString = encryptedString as string; + if (!this.encryptedString) { + return; + } + + const { encType, encPieces } = this.parseEncryptedString(this.encryptedString); + this.encryptionType = encType; + + switch (encType) { + case EncryptionType.AesCbc128_HmacSha256_B64: + case EncryptionType.AesCbc256_HmacSha256_B64: + if (encPieces.length !== 3) { + return; + } + + this.iv = encPieces[0]; + this.data = encPieces[1]; + this.mac = encPieces[2]; + break; + case EncryptionType.AesCbc256_B64: + if (encPieces.length !== 2) { + return; + } + + this.iv = encPieces[0]; + this.data = encPieces[1]; + break; + case EncryptionType.Rsa2048_OaepSha256_B64: + case EncryptionType.Rsa2048_OaepSha1_B64: + if (encPieces.length !== 1) { + return; + } + + this.data = encPieces[0]; + break; + default: + return; + } + } + + private parseEncryptedString(encryptedString: string): { + encType: EncryptionType; + encPieces: string[]; + } { + const headerPieces = encryptedString.split("."); + let encType: EncryptionType; + let encPieces: string[] = null; + + if (headerPieces.length === 2) { + try { + encType = parseInt(headerPieces[0], null); + encPieces = headerPieces[1].split("|"); + } catch (e) { + return; + } + } else { + encPieces = encryptedString.split("|"); + encType = + encPieces.length === 3 + ? EncryptionType.AesCbc128_HmacSha256_B64 + : EncryptionType.AesCbc256_B64; + } + + return { + encType, + encPieces, + }; + } } diff --git a/libs/common/src/models/domain/folder.ts b/libs/common/src/models/domain/folder.ts index 8596e94d76..fcc700b067 100644 --- a/libs/common/src/models/domain/folder.ts +++ b/libs/common/src/models/domain/folder.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { FolderData } from "../data/folderData"; import { FolderView } from "../view/folderView"; @@ -37,4 +39,9 @@ export class Folder extends Domain { null ); } + + static fromJSON(obj: Jsonify) { + const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate); + return Object.assign(new Folder(), obj, { name: EncString.fromJSON(obj.name), revisionDate }); + } } diff --git a/libs/common/src/models/view/folderView.ts b/libs/common/src/models/view/folderView.ts index 731acffba4..dcd1276705 100644 --- a/libs/common/src/models/view/folderView.ts +++ b/libs/common/src/models/view/folderView.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { Folder } from "../domain/folder"; import { ITreeNodeObject } from "../domain/treeNode"; @@ -16,4 +18,9 @@ export class FolderView implements View, ITreeNodeObject { this.id = f.id; this.revisionDate = f.revisionDate; } + + static fromJSON(obj: Jsonify) { + const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate); + return Object.assign(new FolderView(), obj, { revisionDate }); + } } diff --git a/libs/common/src/services/folder/folder.service.ts b/libs/common/src/services/folder/folder.service.ts index 4b3530cb81..2c598a70c1 100644 --- a/libs/common/src/services/folder/folder.service.ts +++ b/libs/common/src/services/folder/folder.service.ts @@ -13,8 +13,8 @@ import { SymmetricCryptoKey } from "../../models/domain/symmetricCryptoKey"; import { FolderView } from "../../models/view/folderView"; export class FolderService implements InternalFolderServiceAbstraction { - private _folders: BehaviorSubject = new BehaviorSubject([]); - private _folderViews: BehaviorSubject = new BehaviorSubject([]); + protected _folders: BehaviorSubject = new BehaviorSubject([]); + protected _folderViews: BehaviorSubject = new BehaviorSubject([]); folders$ = this._folders.asObservable(); folderViews$ = this._folderViews.asObservable();