mirror of
https://github.com/bitwarden/browser.git
synced 2025-02-16 01:21:48 +01:00
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:
parent
9d0dd613fb
commit
5339344630
@ -8,6 +8,7 @@
|
|||||||
**/jest.config.js
|
**/jest.config.js
|
||||||
**/gulpfile.js
|
**/gulpfile.js
|
||||||
|
|
||||||
|
apps/browser/config/config.js
|
||||||
apps/browser/src/content/autofill.js
|
apps/browser/src/content/autofill.js
|
||||||
apps/browser/src/scripts/duo.js
|
apps/browser/src/scripts/duo.js
|
||||||
|
|
||||||
@ -18,3 +19,5 @@ apps/web/config.js
|
|||||||
apps/web/scripts/*.js
|
apps/web/scripts/*.js
|
||||||
apps/web/src/theme.js
|
apps/web/src/theme.js
|
||||||
apps/web/tailwind.config.js
|
apps/web/tailwind.config.js
|
||||||
|
|
||||||
|
apps/cli/config/config.js
|
||||||
|
4
apps/browser/config/base.json
Normal file
4
apps/browser/config/base.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"dev_flags": {},
|
||||||
|
"flags": {}
|
||||||
|
}
|
30
apps/browser/config/config.js
Normal file
30
apps/browser/config/config.js
Normal 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,
|
||||||
|
};
|
6
apps/browser/config/development.json
Normal file
6
apps/browser/config/development.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"devFlags": {
|
||||||
|
"storeSessionDecrypted": false
|
||||||
|
},
|
||||||
|
"flags": {}
|
||||||
|
}
|
3
apps/browser/config/production.json
Normal file
3
apps/browser/config/production.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"flags": {}
|
||||||
|
}
|
@ -54,7 +54,6 @@ import { EventService } from "@bitwarden/common/services/event.service";
|
|||||||
import { ExportService } from "@bitwarden/common/services/export.service";
|
import { ExportService } from "@bitwarden/common/services/export.service";
|
||||||
import { FileUploadService } from "@bitwarden/common/services/fileUpload.service";
|
import { FileUploadService } from "@bitwarden/common/services/fileUpload.service";
|
||||||
import { FolderApiService } from "@bitwarden/common/services/folder/folder-api.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 { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service";
|
||||||
import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service";
|
import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service";
|
||||||
import { NotificationsService } from "@bitwarden/common/services/notifications.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 BrowserMessagingService from "../services/browserMessaging.service";
|
||||||
import BrowserMessagingPrivateModeBackgroundService from "../services/browserMessagingPrivateModeBackground.service";
|
import BrowserMessagingPrivateModeBackgroundService from "../services/browserMessagingPrivateModeBackground.service";
|
||||||
import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service";
|
import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service";
|
||||||
|
import { FolderService } from "../services/folders/folder.service";
|
||||||
import I18nService from "../services/i18n.service";
|
import I18nService from "../services/i18n.service";
|
||||||
import { KeyGenerationService } from "../services/keyGeneration.service";
|
import { KeyGenerationService } from "../services/keyGeneration.service";
|
||||||
import { LocalBackedSessionStorageService } from "../services/localBackedSessionStorage.service";
|
import { LocalBackedSessionStorageService } from "../services/localBackedSessionStorage.service";
|
||||||
|
@ -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() {
|
static async closeLoginTab() {
|
||||||
const tabs = await BrowserApi.tabsQuery({
|
const tabs = await BrowserApi.tabsQuery({
|
||||||
active: true,
|
active: true,
|
||||||
|
35
apps/browser/src/decorators/dev-flag.decorator.spec.ts
Normal file
35
apps/browser/src/decorators/dev-flag.decorator.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
15
apps/browser/src/decorators/dev-flag.decorator.ts
Normal file
15
apps/browser/src/decorators/dev-flag.decorator.ts
Normal 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);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,2 @@
|
|||||||
|
export { browserSession } from "./browser-session.decorator";
|
||||||
|
export { sessionSync } from "./session-sync.decorator";
|
@ -0,0 +1,7 @@
|
|||||||
|
import { SessionSyncer } from "./session-syncer";
|
||||||
|
import { SyncedItemMetadata } from "./sync-item-metadata";
|
||||||
|
|
||||||
|
export interface SessionStorable {
|
||||||
|
__syncedItemMetadata: SyncedItemMetadata[];
|
||||||
|
__sessionSyncers: SessionSyncer[];
|
||||||
|
}
|
@ -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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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`;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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
41
apps/browser/src/flags.ts
Normal 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];
|
||||||
|
}
|
@ -7,6 +7,8 @@ import { BrowserGroupingsComponentState } from "src/models/browserGroupingsCompo
|
|||||||
import { BrowserSendComponentState } from "src/models/browserSendComponentState";
|
import { BrowserSendComponentState } from "src/models/browserSendComponentState";
|
||||||
|
|
||||||
export abstract class StateService extends BaseStateServiceAbstraction<Account> {
|
export abstract class StateService extends BaseStateServiceAbstraction<Account> {
|
||||||
|
abstract getFromSessionMemory<T>(key: string): Promise<T>;
|
||||||
|
abstract setInSessionMemory(key: string, value: any): Promise<void>;
|
||||||
getBrowserGroupingComponentState: (
|
getBrowserGroupingComponentState: (
|
||||||
options?: StorageOptions
|
options?: StorageOptions
|
||||||
) => Promise<BrowserGroupingsComponentState>;
|
) => Promise<BrowserGroupingsComponentState>;
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
|
||||||
|
|
||||||
|
import { BrowserApi } from "../browser/browserApi";
|
||||||
|
|
||||||
export default class BrowserMessagingService implements MessagingService {
|
export default class BrowserMessagingService implements MessagingService {
|
||||||
send(subscriber: string, arg: any = {}) {
|
send(subscriber: string, arg: any = {}) {
|
||||||
const message = Object.assign({}, { command: subscriber }, arg);
|
return BrowserApi.sendMessage(subscriber, arg);
|
||||||
chrome.runtime.sendMessage(message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
15
apps/browser/src/services/folders/folder.service.ts
Normal file
15
apps/browser/src/services/folders/folder.service.ts
Normal 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[]>;
|
||||||
|
}
|
@ -1,8 +1,11 @@
|
|||||||
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";
|
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 { EncString } from "@bitwarden/common/models/domain/encString";
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey";
|
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 { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service";
|
||||||
import BrowserLocalStorageService from "./browserLocalStorage.service";
|
import BrowserLocalStorageService from "./browserLocalStorage.service";
|
||||||
import BrowserMemoryStorageService from "./browserMemoryStorage.service";
|
import BrowserMemoryStorageService from "./browserMemoryStorage.service";
|
||||||
@ -12,8 +15,8 @@ const keys = {
|
|||||||
sessionKey: "session",
|
sessionKey: "session",
|
||||||
};
|
};
|
||||||
|
|
||||||
export class LocalBackedSessionStorageService extends AbstractStorageService {
|
export class LocalBackedSessionStorageService extends AbstractCachedStorageService {
|
||||||
private cache = new Map<string, any>();
|
private cache = new Map<string, unknown>();
|
||||||
private localStorage = new BrowserLocalStorageService();
|
private localStorage = new BrowserLocalStorageService();
|
||||||
private sessionStorage = new BrowserMemoryStorageService();
|
private sessionStorage = new BrowserMemoryStorageService();
|
||||||
|
|
||||||
@ -26,23 +29,27 @@ export class LocalBackedSessionStorageService extends AbstractStorageService {
|
|||||||
|
|
||||||
async get<T>(key: string): Promise<T> {
|
async get<T>(key: string): Promise<T> {
|
||||||
if (this.cache.has(key)) {
|
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());
|
const session = await this.getLocalSession(await this.getSessionEncKey());
|
||||||
if (session == null || !Object.keys(session).includes(key)) {
|
if (session == null || !Object.keys(session).includes(key)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cache.set(key, session[key]);
|
this.cache.set(key, session[key]);
|
||||||
return this.cache.get(key);
|
return this.cache.get(key) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async has(key: string): Promise<boolean> {
|
async has(key: string): Promise<boolean> {
|
||||||
return (await this.get(key)) != null;
|
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) {
|
if (obj == null) {
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
} else {
|
} else {
|
||||||
@ -59,13 +66,17 @@ export class LocalBackedSessionStorageService extends AbstractStorageService {
|
|||||||
await this.save(key, null);
|
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);
|
const local = await this.localStorage.get<string>(keys.sessionKey);
|
||||||
|
|
||||||
if (local == null) {
|
if (local == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (devFlagEnabled("storeSessionDecrypted")) {
|
||||||
|
return local as any as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
const sessionJson = await this.encryptService.decryptToUtf8(new EncString(local), encKey);
|
const sessionJson = await this.encryptService.decryptToUtf8(new EncString(local), encKey);
|
||||||
if (sessionJson == null) {
|
if (sessionJson == null) {
|
||||||
// Error with decryption -- session is lost, delete state and key and start over
|
// 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);
|
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 jsonSession = JSON.stringify(session);
|
||||||
const encSession = await this.encryptService.encrypt(jsonSession, key);
|
const encSession = await this.encryptService.encrypt(jsonSession, key);
|
||||||
|
|
||||||
@ -87,14 +117,12 @@ export class LocalBackedSessionStorageService extends AbstractStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSessionEncKey(): Promise<SymmetricCryptoKey> {
|
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) {
|
if (storedKey == null || Object.keys(storedKey).length == 0) {
|
||||||
storedKey = await this.keyGenerationService.makeEphemeralKey();
|
storedKey = await this.keyGenerationService.makeEphemeralKey();
|
||||||
await this.setSessionEncKey(storedKey);
|
await this.setSessionEncKey(storedKey);
|
||||||
}
|
}
|
||||||
return SymmetricCryptoKey.fromJSON(
|
return SymmetricCryptoKey.fromJSON(storedKey);
|
||||||
Object.create(SymmetricCryptoKey.prototype, Object.getOwnPropertyDescriptors(storedKey))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSessionEncKey(input: SymmetricCryptoKey): Promise<void> {
|
async setSessionEncKey(input: SymmetricCryptoKey): Promise<void> {
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||||
|
|
||||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
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 { SendType } from "@bitwarden/common/enums/sendType";
|
||||||
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
|
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
|
||||||
import { GlobalState } from "@bitwarden/common/models/domain/globalState";
|
import { GlobalState } from "@bitwarden/common/models/domain/globalState";
|
||||||
@ -14,12 +17,12 @@ import { BrowserComponentState } from "../models/browserComponentState";
|
|||||||
import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState";
|
import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState";
|
||||||
import { BrowserSendComponentState } from "../models/browserSendComponentState";
|
import { BrowserSendComponentState } from "../models/browserSendComponentState";
|
||||||
|
|
||||||
|
import { LocalBackedSessionStorageService } from "./localBackedSessionStorage.service";
|
||||||
import { StateService } from "./state.service";
|
import { StateService } from "./state.service";
|
||||||
|
|
||||||
describe("Browser State Service", () => {
|
describe("Browser State Service", () => {
|
||||||
let secureStorageService: SubstituteOf<AbstractStorageService>;
|
let secureStorageService: SubstituteOf<AbstractStorageService>;
|
||||||
let diskStorageService: SubstituteOf<AbstractStorageService>;
|
let diskStorageService: SubstituteOf<AbstractStorageService>;
|
||||||
let memoryStorageService: SubstituteOf<AbstractStorageService>;
|
|
||||||
let logService: SubstituteOf<LogService>;
|
let logService: SubstituteOf<LogService>;
|
||||||
let stateMigrationService: SubstituteOf<StateMigrationService>;
|
let stateMigrationService: SubstituteOf<StateMigrationService>;
|
||||||
let stateFactory: SubstituteOf<StateFactory<GlobalState, Account>>;
|
let stateFactory: SubstituteOf<StateFactory<GlobalState, Account>>;
|
||||||
@ -33,7 +36,6 @@ describe("Browser State Service", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
secureStorageService = Substitute.for();
|
secureStorageService = Substitute.for();
|
||||||
diskStorageService = Substitute.for();
|
diskStorageService = Substitute.for();
|
||||||
memoryStorageService = Substitute.for();
|
|
||||||
logService = Substitute.for();
|
logService = Substitute.for();
|
||||||
stateMigrationService = Substitute.for();
|
stateMigrationService = Substitute.for();
|
||||||
stateFactory = Substitute.for();
|
stateFactory = Substitute.for();
|
||||||
@ -44,66 +46,104 @@ describe("Browser State Service", () => {
|
|||||||
profile: { userId: userId },
|
profile: { userId: userId },
|
||||||
});
|
});
|
||||||
state.activeUserId = 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", () => {
|
describe("direct memory storage access", () => {
|
||||||
it("should return a BrowserGroupingsComponentState", async () => {
|
let memoryStorageService: AbstractCachedStorageService;
|
||||||
state.accounts[userId].groupings = new BrowserGroupingsComponentState();
|
|
||||||
|
|
||||||
const actual = await sut.getBrowserGroupingComponentState();
|
beforeEach(() => {
|
||||||
expect(actual).toBeInstanceOf(BrowserGroupingsComponentState);
|
// 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", () => {
|
describe("state methods", () => {
|
||||||
it("should return a BrowserComponentState", async () => {
|
let memoryStorageService: SubstituteOf<AbstractStorageService>;
|
||||||
const componentState = new BrowserComponentState();
|
|
||||||
componentState.scrollY = 0;
|
|
||||||
componentState.searchText = "test";
|
|
||||||
state.accounts[userId].ciphers = componentState;
|
|
||||||
|
|
||||||
const actual = await sut.getBrowserCipherComponentState();
|
beforeEach(() => {
|
||||||
expect(actual).toStrictEqual(componentState);
|
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", () => {
|
describe("getBrowserGroupingComponentState", () => {
|
||||||
it("should return a BrowserSendComponentState", async () => {
|
it("should return a BrowserGroupingsComponentState", async () => {
|
||||||
const sendState = new BrowserSendComponentState();
|
state.accounts[userId].groupings = new BrowserGroupingsComponentState();
|
||||||
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();
|
const actual = await sut.getBrowserGroupingComponentState();
|
||||||
expect(actual).toBeInstanceOf(BrowserSendComponentState);
|
expect(actual).toBeInstanceOf(BrowserGroupingsComponentState);
|
||||||
expect(actual).toMatchObject(sendState);
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("getBrowserSendTypeComponentState", () => {
|
describe("getBrowserCipherComponentState", () => {
|
||||||
it("should return a BrowserComponentState", async () => {
|
it("should return a BrowserComponentState", async () => {
|
||||||
const componentState = new BrowserComponentState();
|
const componentState = new BrowserComponentState();
|
||||||
componentState.scrollY = 0;
|
componentState.scrollY = 0;
|
||||||
componentState.searchText = "test";
|
componentState.searchText = "test";
|
||||||
state.accounts[userId].sendType = componentState;
|
state.accounts[userId].ciphers = componentState;
|
||||||
|
|
||||||
const actual = await sut.getBrowserSendTypeComponentState();
|
const actual = await sut.getBrowserCipherComponentState();
|
||||||
expect(actual).toStrictEqual(componentState);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AbstractCachedStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||||
import { GlobalState } from "@bitwarden/common/models/domain/globalState";
|
import { GlobalState } from "@bitwarden/common/models/domain/globalState";
|
||||||
import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions";
|
import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions";
|
||||||
import {
|
import {
|
||||||
@ -16,6 +17,16 @@ export class StateService
|
|||||||
extends BaseStateService<GlobalState, Account>
|
extends BaseStateService<GlobalState, Account>
|
||||||
implements StateServiceAbstraction
|
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) {
|
async addAccount(account: Account) {
|
||||||
// Apply browser overrides to default account values
|
// Apply browser overrides to default account values
|
||||||
account = new Account(account);
|
account = new Account(account);
|
||||||
|
@ -1,26 +1,32 @@
|
|||||||
// Add chrome storage api
|
// 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 QUOTA_BYTES = 10;
|
||||||
const getBytesInUse = jest.fn();
|
const storage = {
|
||||||
const clear = jest.fn();
|
local: {
|
||||||
global.chrome = {
|
set: jest.fn(),
|
||||||
storage: {
|
get: jest.fn(),
|
||||||
local: {
|
remove: jest.fn(),
|
||||||
set,
|
QUOTA_BYTES,
|
||||||
get,
|
getBytesInUse: jest.fn(),
|
||||||
remove,
|
clear: jest.fn(),
|
||||||
QUOTA_BYTES,
|
|
||||||
getBytesInUse,
|
|
||||||
clear,
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
set,
|
|
||||||
get,
|
|
||||||
has,
|
|
||||||
remove,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
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;
|
} as any;
|
||||||
|
@ -6,6 +6,7 @@ const CopyWebpackPlugin = require("copy-webpack-plugin");
|
|||||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||||
const { AngularWebpackPlugin } = require("@ngtools/webpack");
|
const { AngularWebpackPlugin } = require("@ngtools/webpack");
|
||||||
const TerserPlugin = require("terser-webpack-plugin");
|
const TerserPlugin = require("terser-webpack-plugin");
|
||||||
|
const configurator = require("./config/config");
|
||||||
|
|
||||||
if (process.env.NODE_ENV == null) {
|
if (process.env.NODE_ENV == null) {
|
||||||
process.env.NODE_ENV = "development";
|
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;
|
const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2;
|
||||||
|
|
||||||
console.log(`Building Manifest Version ${manifestVersion} app`);
|
console.log(`Building Manifest Version ${manifestVersion} app`);
|
||||||
|
const envConfig = configurator.load(ENV);
|
||||||
|
configurator.log(envConfig);
|
||||||
|
|
||||||
const moduleRules = [
|
const moduleRules = [
|
||||||
{
|
{
|
||||||
@ -116,6 +119,10 @@ const plugins = [
|
|||||||
exclude: [/content\/.*/, /notification\/.*/],
|
exclude: [/content\/.*/, /notification\/.*/],
|
||||||
filename: "[file].map",
|
filename: "[file].map",
|
||||||
}),
|
}),
|
||||||
|
new webpack.EnvironmentPlugin({
|
||||||
|
FLAGS: envConfig.flags,
|
||||||
|
DEV_FLAGS: ENV === "development" ? envConfig.devFlags : {},
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
function load(envName) {
|
function load(envName) {
|
||||||
return {
|
return {
|
||||||
...loadConfig(envName),
|
...loadConfig(envName),
|
||||||
|
@ -192,4 +192,12 @@ describe("EncString", () => {
|
|||||||
cryptoService.received().decryptToUtf8(encString, key);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { FolderData } from "@bitwarden/common/models/data/folderData";
|
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 { Folder } from "@bitwarden/common/models/domain/folder";
|
||||||
|
|
||||||
import { mockEnc } from "../../utils";
|
import { mockEnc } from "../../utils";
|
||||||
@ -38,4 +39,27 @@ describe("Folder", () => {
|
|||||||
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
22
libs/common/spec/models/view/folderView.spec.ts
Normal file
22
libs/common/spec/models/view/folderView.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -6,3 +6,7 @@ export abstract class AbstractStorageService {
|
|||||||
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;
|
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>;
|
||||||
abstract remove(key: string, 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>;
|
||||||
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
import { IEncrypted } from "@bitwarden/common/interfaces/IEncrypted";
|
import { IEncrypted } from "@bitwarden/common/interfaces/IEncrypted";
|
||||||
|
|
||||||
import { CryptoService } from "../../abstractions/crypto.service";
|
import { CryptoService } from "../../abstractions/crypto.service";
|
||||||
@ -21,80 +23,9 @@ export class EncString implements IEncrypted {
|
|||||||
mac?: string
|
mac?: string
|
||||||
) {
|
) {
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
// data and header
|
this.initFromData(encryptedStringOrType as EncryptionType, data, iv, mac);
|
||||||
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;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
encPieces = this.encryptedString.split("|");
|
this.initFromEncryptedString(encryptedStringOrType as string);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,4 +64,100 @@ export class EncString implements IEncrypted {
|
|||||||
get dataBytes(): ArrayBuffer {
|
get dataBytes(): ArrayBuffer {
|
||||||
return this.data == null ? null : Utils.fromB64ToArray(this.data).buffer;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
import { FolderData } from "../data/folderData";
|
import { FolderData } from "../data/folderData";
|
||||||
import { FolderView } from "../view/folderView";
|
import { FolderView } from "../view/folderView";
|
||||||
|
|
||||||
@ -37,4 +39,9 @@ export class Folder extends Domain {
|
|||||||
null
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
import { Folder } from "../domain/folder";
|
import { Folder } from "../domain/folder";
|
||||||
import { ITreeNodeObject } from "../domain/treeNode";
|
import { ITreeNodeObject } from "../domain/treeNode";
|
||||||
|
|
||||||
@ -16,4 +18,9 @@ export class FolderView implements View, ITreeNodeObject {
|
|||||||
this.id = f.id;
|
this.id = f.id;
|
||||||
this.revisionDate = f.revisionDate;
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,8 +13,8 @@ import { SymmetricCryptoKey } from "../../models/domain/symmetricCryptoKey";
|
|||||||
import { FolderView } from "../../models/view/folderView";
|
import { FolderView } from "../../models/view/folderView";
|
||||||
|
|
||||||
export class FolderService implements InternalFolderServiceAbstraction {
|
export class FolderService implements InternalFolderServiceAbstraction {
|
||||||
private _folders: BehaviorSubject<Folder[]> = new BehaviorSubject([]);
|
protected _folders: BehaviorSubject<Folder[]> = new BehaviorSubject([]);
|
||||||
private _folderViews: BehaviorSubject<FolderView[]> = new BehaviorSubject([]);
|
protected _folderViews: BehaviorSubject<FolderView[]> = new BehaviorSubject([]);
|
||||||
|
|
||||||
folders$ = this._folders.asObservable();
|
folders$ = this._folders.asObservable();
|
||||||
folderViews$ = this._folderViews.asObservable();
|
folderViews$ = this._folderViews.asObservable();
|
||||||
|
Loading…
Reference in New Issue
Block a user