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
This commit is contained in:
Matt Gibson 2022-08-16 06:05:03 -06:00 committed by GitHub
parent 9d0dd613fb
commit 5339344630
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1018 additions and 163 deletions

View File

@ -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

View File

@ -0,0 +1,4 @@
{
"dev_flags": {},
"flags": {}
}

View File

@ -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,
};

View File

@ -0,0 +1,6 @@
{
"devFlags": {
"storeSessionDecrypted": false
},
"flags": {}
}

View File

@ -0,0 +1,3 @@
{
"flags": {}
}

View File

@ -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";

View File

@ -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,

View File

@ -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();
});
});

View File

@ -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);
};
};
}

View File

@ -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();
});
});
});

View File

@ -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<TCtor extends Constructor<any>>(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;
}
};
}

View File

@ -0,0 +1,2 @@
export { browserSession } from "./browser-session.decorator";
export { sessionSync } from "./session-sync.decorator";

View File

@ -0,0 +1,7 @@
import { SessionSyncer } from "./session-syncer";
import { SyncedItemMetadata } from "./sync-item-metadata";
export interface SessionStorable {
__syncedItemMetadata: SyncedItemMetadata[];
__sessionSyncers: SessionSyncer[];
}

View File

@ -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,
}),
]);
});
});

View File

@ -0,0 +1,51 @@
import { Jsonify } from "type-fest";
import { SessionStorable } from "./session-storable";
class BuildOptions<T> {
ctor?: new () => T;
initializer?: (keyValuePair: Jsonify<T>) => 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<T>(buildOptions: BuildOptions<T>) {
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,
});
};
}

View File

@ -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<StateService>;
let sut: SessionSyncer;
let behaviorSubject: BehaviorSubject<string>;
beforeEach(() => {
behaviorSubject = new BehaviorSubject<string>("");
jest.spyOn(chrome.runtime, "getManifest").mockReturnValue({
name: "bitwarden-test",
version: "0.0.0",
manifest_version: 3,
});
stateService = mock<StateService>();
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);
});
});
});

View File

@ -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<any>,
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`;
}
}

View File

@ -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);
}
}

View File

@ -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"]);
});
});

41
apps/browser/src/flags.ts Normal file
View File

@ -0,0 +1,41 @@
function getFlags<T>(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<Flags>(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<DevFlags>(process.env.DEV_FLAGS);
return devFlags[flag] == null || devFlags[flag];
}

View File

@ -7,6 +7,8 @@ import { BrowserGroupingsComponentState } from "src/models/browserGroupingsCompo
import { BrowserSendComponentState } from "src/models/browserSendComponentState";
export abstract class StateService extends BaseStateServiceAbstraction<Account> {
abstract getFromSessionMemory<T>(key: string): Promise<T>;
abstract setInSessionMemory(key: string, value: any): Promise<void>;
getBrowserGroupingComponentState: (
options?: StorageOptions
) => Promise<BrowserGroupingsComponentState>;

View File

@ -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);
}
}

View File

@ -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<Folder[]>;
@sessionSync({ initializer: FolderView.fromJSON, initializeAsArray: true })
protected _folderViews: BehaviorSubject<FolderView[]>;
}

View File

@ -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<string, any>();
export class LocalBackedSessionStorageService extends AbstractCachedStorageService {
private cache = new Map<string, unknown>();
private localStorage = new BrowserLocalStorageService();
private sessionStorage = new BrowserMemoryStorageService();
@ -26,23 +29,27 @@ export class LocalBackedSessionStorageService extends AbstractStorageService {
async get<T>(key: string): Promise<T> {
if (this.cache.has(key)) {
return this.cache.get(key);
return this.cache.get(key) as T;
}
return await this.getBypassCache(key);
}
async getBypassCache<T>(key: string): Promise<T> {
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<boolean> {
return (await this.get(key)) != null;
}
async save(key: string, obj: any): Promise<void> {
async save<T>(key: string, obj: T): Promise<void> {
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<any> {
async getLocalSession(encKey: SymmetricCryptoKey): Promise<Record<string, unknown>> {
const local = await this.localStorage.get<string>(keys.sessionKey);
if (local == null) {
return null;
}
if (devFlagEnabled("storeSessionDecrypted")) {
return local as any as Record<string, unknown>;
}
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<string, unknown>, key: SymmetricCryptoKey) {
if (devFlagEnabled("storeSessionDecrypted")) {
await this.setDecryptedLocalSession(session);
} else {
await this.setEncryptedLocalSession(session, key);
}
}
@devFlag("storeSessionDecrypted")
async setDecryptedLocalSession(session: Record<string, unknown>): Promise<void> {
// 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<string, unknown>, 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<SymmetricCryptoKey> {
let storedKey = (await this.sessionStorage.get(keys.encKey)) as SymmetricCryptoKey;
let storedKey = await this.sessionStorage.get<SymmetricCryptoKey>(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<void> {

View File

@ -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<AbstractStorageService>;
let diskStorageService: SubstituteOf<AbstractStorageService>;
let memoryStorageService: SubstituteOf<AbstractStorageService>;
let logService: SubstituteOf<LogService>;
let stateMigrationService: SubstituteOf<StateMigrationService>;
let stateFactory: SubstituteOf<StateFactory<GlobalState, Account>>;
@ -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<AbstractStorageService>;
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, number>([
[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, number>([
[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);
});
});
});
});

View File

@ -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<GlobalState, Account>
implements StateServiceAbstraction
{
async getFromSessionMemory<T>(key: string): Promise<T> {
return this.memoryStorageService instanceof AbstractCachedStorageService
? await this.memoryStorageService.getBypassCache<T>(key)
: await this.memoryStorageService.get<T>(key);
}
async setInSessionMemory(key: string, value: any): Promise<void> {
await this.memoryStorageService.save(key, value);
}
async addAccount(account: Account) {
// Apply browser overrides to default account values
account = new Account(account);

View File

@ -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;

View File

@ -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 = {

View File

@ -1,4 +1,3 @@
/* eslint-disable no-console */
function load(envName) {
return {
...loadConfig(envName),

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -6,3 +6,7 @@ export abstract class AbstractStorageService {
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;
abstract remove(key: string, options?: StorageOptions): Promise<void>;
}
export abstract class AbstractCachedStorageService extends AbstractStorageService {
abstract getBypassCache<T>(key: string, options?: StorageOptions): Promise<T>;
}

View File

@ -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>): 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,
};
}
}

View File

@ -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<Folder>) {
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
return Object.assign(new Folder(), obj, { name: EncString.fromJSON(obj.name), revisionDate });
}
}

View File

@ -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<FolderView>) {
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
return Object.assign(new FolderView(), obj, { revisionDate });
}
}

View File

@ -13,8 +13,8 @@ import { SymmetricCryptoKey } from "../../models/domain/symmetricCryptoKey";
import { FolderView } from "../../models/view/folderView";
export class FolderService implements InternalFolderServiceAbstraction {
private _folders: BehaviorSubject<Folder[]> = new BehaviorSubject([]);
private _folderViews: BehaviorSubject<FolderView[]> = new BehaviorSubject([]);
protected _folders: BehaviorSubject<Folder[]> = new BehaviorSubject([]);
protected _folderViews: BehaviorSubject<FolderView[]> = new BehaviorSubject([]);
folders$ = this._folders.asObservable();
folderViews$ = this._folderViews.asObservable();