mirror of
https://github.com/bitwarden/browser
synced 2025-12-19 17:53:39 +00:00
Merge branch 'main' of github.com:bitwarden/clients
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"devFlags": {
|
"devFlags": {
|
||||||
"storeSessionDecrypted": false,
|
|
||||||
"managedEnvironment": {
|
"managedEnvironment": {
|
||||||
"base": "https://localhost:8080"
|
"base": "https://localhost:8080"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,9 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory
|
|||||||
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
|
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
|
||||||
// eslint-disable-next-line no-restricted-imports -- Used for dependency creation
|
// eslint-disable-next-line no-restricted-imports -- Used for dependency creation
|
||||||
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
|
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
|
||||||
|
import { Lazy } from "@bitwarden/common/platform/misc/lazy";
|
||||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||||
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||||
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
||||||
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
|
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
|
||||||
@@ -404,32 +406,51 @@ export default class MainBackground {
|
|||||||
self,
|
self,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mv3MemoryStorageCreator = (partitionName: string) => {
|
// Creates a session key for mv3 storage of large memory items
|
||||||
if (this.popupOnlyContext) {
|
const sessionKey = new Lazy(async () => {
|
||||||
return new ForegroundMemoryStorageService(partitionName);
|
// Key already in session storage
|
||||||
|
const sessionStorage = new BrowserMemoryStorageService();
|
||||||
|
const existingKey = await sessionStorage.get<SymmetricCryptoKey>("session-key");
|
||||||
|
if (existingKey) {
|
||||||
|
if (sessionStorage.valuesRequireDeserialization) {
|
||||||
|
return SymmetricCryptoKey.fromJSON(existingKey);
|
||||||
|
}
|
||||||
|
return existingKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New key
|
||||||
|
const { derivedKey } = await this.keyGenerationService.createKeyWithPurpose(
|
||||||
|
128,
|
||||||
|
"ephemeral",
|
||||||
|
"bitwarden-ephemeral",
|
||||||
|
);
|
||||||
|
await sessionStorage.save("session-key", derivedKey);
|
||||||
|
return derivedKey;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mv3MemoryStorageCreator = () => {
|
||||||
|
if (this.popupOnlyContext) {
|
||||||
|
return new ForegroundMemoryStorageService();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Consider using multithreaded encrypt service in popup only context
|
|
||||||
return new LocalBackedSessionStorageService(
|
return new LocalBackedSessionStorageService(
|
||||||
this.logService,
|
sessionKey,
|
||||||
|
this.storageService,
|
||||||
new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false),
|
new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false),
|
||||||
this.keyGenerationService,
|
|
||||||
new BrowserLocalStorageService(),
|
|
||||||
new BrowserMemoryStorageService(),
|
|
||||||
this.platformUtilsService,
|
this.platformUtilsService,
|
||||||
partitionName,
|
this.logService,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.secureStorageService = this.storageService; // secure storage is not supported in browsers, so we use local storage and warn users when it is used
|
this.secureStorageService = this.storageService; // secure storage is not supported in browsers, so we use local storage and warn users when it is used
|
||||||
this.memoryStorageService = BrowserApi.isManifestVersion(3)
|
|
||||||
? mv3MemoryStorageCreator("stateService")
|
|
||||||
: new MemoryStorageService();
|
|
||||||
this.memoryStorageForStateProviders = BrowserApi.isManifestVersion(3)
|
this.memoryStorageForStateProviders = BrowserApi.isManifestVersion(3)
|
||||||
? new BrowserMemoryStorageService() // mv3 stores to storage.session
|
? new BrowserMemoryStorageService() // mv3 stores to storage.session
|
||||||
: new BackgroundMemoryStorageService(); // mv2 stores to memory
|
: new BackgroundMemoryStorageService(); // mv2 stores to memory
|
||||||
|
this.memoryStorageService = BrowserApi.isManifestVersion(3)
|
||||||
|
? this.memoryStorageForStateProviders // manifest v3 can reuse the same storage. They are split for v2 due to lacking a good sync mechanism, which isn't true for v3
|
||||||
|
: new MemoryStorageService();
|
||||||
this.largeObjectMemoryStorageForStateProviders = BrowserApi.isManifestVersion(3)
|
this.largeObjectMemoryStorageForStateProviders = BrowserApi.isManifestVersion(3)
|
||||||
? mv3MemoryStorageCreator("stateProviders") // mv3 stores to local-backed session storage
|
? mv3MemoryStorageCreator() // mv3 stores to local-backed session storage
|
||||||
: this.memoryStorageForStateProviders; // mv2 stores to the same location
|
: this.memoryStorageForStateProviders; // mv2 stores to the same location
|
||||||
|
|
||||||
const storageServiceProvider = new BrowserStorageServiceProvider(
|
const storageServiceProvider = new BrowserStorageServiceProvider(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
AbstractStorageService,
|
AbstractStorageService,
|
||||||
ObservableStorageService,
|
ObservableStorageService,
|
||||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||||
|
import { Lazy } from "@bitwarden/common/platform/misc/lazy";
|
||||||
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
||||||
|
|
||||||
import { BrowserApi } from "../../browser/browser-api";
|
import { BrowserApi } from "../../browser/browser-api";
|
||||||
@@ -17,10 +19,10 @@ import {
|
|||||||
KeyGenerationServiceInitOptions,
|
KeyGenerationServiceInitOptions,
|
||||||
keyGenerationServiceFactory,
|
keyGenerationServiceFactory,
|
||||||
} from "./key-generation-service.factory";
|
} from "./key-generation-service.factory";
|
||||||
import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory";
|
import { LogServiceInitOptions, logServiceFactory } from "./log-service.factory";
|
||||||
import {
|
import {
|
||||||
platformUtilsServiceFactory,
|
|
||||||
PlatformUtilsServiceInitOptions,
|
PlatformUtilsServiceInitOptions,
|
||||||
|
platformUtilsServiceFactory,
|
||||||
} from "./platform-utils-service.factory";
|
} from "./platform-utils-service.factory";
|
||||||
|
|
||||||
export type DiskStorageServiceInitOptions = FactoryOptions;
|
export type DiskStorageServiceInitOptions = FactoryOptions;
|
||||||
@@ -70,13 +72,23 @@ export function memoryStorageServiceFactory(
|
|||||||
return factory(cache, "memoryStorageService", opts, async () => {
|
return factory(cache, "memoryStorageService", opts, async () => {
|
||||||
if (BrowserApi.isManifestVersion(3)) {
|
if (BrowserApi.isManifestVersion(3)) {
|
||||||
return new LocalBackedSessionStorageService(
|
return new LocalBackedSessionStorageService(
|
||||||
await logServiceFactory(cache, opts),
|
new Lazy(async () => {
|
||||||
await encryptServiceFactory(cache, opts),
|
const existingKey = await (
|
||||||
await keyGenerationServiceFactory(cache, opts),
|
await sessionStorageServiceFactory(cache, opts)
|
||||||
|
).get<SymmetricCryptoKey>("session-key");
|
||||||
|
if (existingKey) {
|
||||||
|
return existingKey;
|
||||||
|
}
|
||||||
|
const { derivedKey } = await (
|
||||||
|
await keyGenerationServiceFactory(cache, opts)
|
||||||
|
).createKeyWithPurpose(128, "ephemeral", "bitwarden-ephemeral");
|
||||||
|
await (await sessionStorageServiceFactory(cache, opts)).save("session-key", derivedKey);
|
||||||
|
return derivedKey;
|
||||||
|
}),
|
||||||
await diskStorageServiceFactory(cache, opts),
|
await diskStorageServiceFactory(cache, opts),
|
||||||
await sessionStorageServiceFactory(cache, opts),
|
await encryptServiceFactory(cache, opts),
|
||||||
await platformUtilsServiceFactory(cache, opts),
|
await platformUtilsServiceFactory(cache, opts),
|
||||||
"serviceFactories",
|
await logServiceFactory(cache, opts),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return new MemoryStorageService();
|
return new MemoryStorageService();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jest.mock("../flags", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
class TestClass {
|
class TestClass {
|
||||||
@devFlag("storeSessionDecrypted") test() {
|
@devFlag("managedEnvironment") test() {
|
||||||
return "test";
|
return "test";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export type Flags = {
|
|||||||
// required to avoid linting errors when there are no flags
|
// required to avoid linting errors when there are no flags
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
export type DevFlags = {
|
export type DevFlags = {
|
||||||
storeSessionDecrypted?: boolean;
|
|
||||||
managedEnvironment?: GroupPolicyEnvironment;
|
managedEnvironment?: GroupPolicyEnvironment;
|
||||||
} & SharedDevFlags;
|
} & SharedDevFlags;
|
||||||
|
|
||||||
|
|||||||
@@ -1,412 +1,200 @@
|
|||||||
import { mock, MockProxy } from "jest-mock-extended";
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
|
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import {
|
import { Lazy } from "@bitwarden/common/platform/misc/lazy";
|
||||||
AbstractMemoryStorageService,
|
|
||||||
AbstractStorageService,
|
|
||||||
StorageUpdate,
|
|
||||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
|
import { FakeStorageService, makeEncString } from "@bitwarden/common/spec";
|
||||||
import { BrowserApi } from "../browser/browser-api";
|
|
||||||
|
|
||||||
import { LocalBackedSessionStorageService } from "./local-backed-session-storage.service";
|
import { LocalBackedSessionStorageService } from "./local-backed-session-storage.service";
|
||||||
|
|
||||||
describe.skip("LocalBackedSessionStorage", () => {
|
describe("LocalBackedSessionStorage", () => {
|
||||||
const sendMessageWithResponseSpy: jest.SpyInstance = jest.spyOn(
|
const sessionKey = new SymmetricCryptoKey(
|
||||||
BrowserApi,
|
Utils.fromUtf8ToArray("00000000000000000000000000000000"),
|
||||||
"sendMessageWithResponse",
|
|
||||||
);
|
);
|
||||||
|
let localStorage: FakeStorageService;
|
||||||
let encryptService: MockProxy<EncryptService>;
|
let encryptService: MockProxy<EncryptService>;
|
||||||
let keyGenerationService: MockProxy<KeyGenerationService>;
|
|
||||||
let localStorageService: MockProxy<AbstractStorageService>;
|
|
||||||
let sessionStorageService: MockProxy<AbstractMemoryStorageService>;
|
|
||||||
let logService: MockProxy<LogService>;
|
|
||||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||||
|
let logService: MockProxy<LogService>;
|
||||||
let cache: Record<string, unknown>;
|
|
||||||
const testObj = { a: 1, b: 2 };
|
|
||||||
const stringifiedTestObj = JSON.stringify(testObj);
|
|
||||||
|
|
||||||
const key = new SymmetricCryptoKey(Utils.fromUtf8ToArray("00000000000000000000000000000000"));
|
|
||||||
let getSessionKeySpy: jest.SpyInstance;
|
|
||||||
let sendUpdateSpy: jest.SpyInstance<void, [storageUpdate: StorageUpdate]>;
|
|
||||||
const mockEnc = (input: string) => Promise.resolve(new EncString("ENCRYPTED" + input));
|
|
||||||
|
|
||||||
let sut: LocalBackedSessionStorageService;
|
let sut: LocalBackedSessionStorageService;
|
||||||
|
|
||||||
const mockExistingSessionKey = (key: SymmetricCryptoKey) => {
|
|
||||||
sessionStorageService.get.mockImplementation((storageKey) => {
|
|
||||||
if (storageKey === "localEncryptionKey_test") {
|
|
||||||
return Promise.resolve(key?.toJSON());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject("No implementation for " + storageKey);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sendMessageWithResponseSpy.mockResolvedValue(null);
|
localStorage = new FakeStorageService();
|
||||||
logService = mock<LogService>();
|
|
||||||
encryptService = mock<EncryptService>();
|
encryptService = mock<EncryptService>();
|
||||||
keyGenerationService = mock<KeyGenerationService>();
|
platformUtilsService = mock<PlatformUtilsService>();
|
||||||
localStorageService = mock<AbstractStorageService>();
|
logService = mock<LogService>();
|
||||||
sessionStorageService = mock<AbstractMemoryStorageService>();
|
|
||||||
|
|
||||||
sut = new LocalBackedSessionStorageService(
|
sut = new LocalBackedSessionStorageService(
|
||||||
logService,
|
new Lazy(async () => sessionKey),
|
||||||
|
localStorage,
|
||||||
encryptService,
|
encryptService,
|
||||||
keyGenerationService,
|
|
||||||
localStorageService,
|
|
||||||
sessionStorageService,
|
|
||||||
platformUtilsService,
|
platformUtilsService,
|
||||||
"test",
|
logService,
|
||||||
);
|
);
|
||||||
|
|
||||||
cache = sut["cachedSession"];
|
|
||||||
|
|
||||||
keyGenerationService.createKeyWithPurpose.mockResolvedValue({
|
|
||||||
derivedKey: key,
|
|
||||||
salt: "bitwarden-ephemeral",
|
|
||||||
material: null, // Not used
|
|
||||||
});
|
|
||||||
|
|
||||||
getSessionKeySpy = jest.spyOn(sut, "getSessionEncKey");
|
|
||||||
getSessionKeySpy.mockResolvedValue(key);
|
|
||||||
|
|
||||||
// sendUpdateSpy = jest.spyOn(sut, "sendUpdate");
|
|
||||||
// sendUpdateSpy.mockReturnValue();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("get", () => {
|
describe("get", () => {
|
||||||
describe("in local cache or external context cache", () => {
|
it("return the cached value when one is cached", async () => {
|
||||||
it("should return from local cache", async () => {
|
sut["cache"]["test"] = "cached";
|
||||||
cache["test"] = stringifiedTestObj;
|
const result = await sut.get("test");
|
||||||
const result = await sut.get("test");
|
expect(result).toEqual("cached");
|
||||||
expect(result).toStrictEqual(testObj);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return from external context cache when local cache is not available", async () => {
|
|
||||||
sendMessageWithResponseSpy.mockResolvedValue(stringifiedTestObj);
|
|
||||||
const result = await sut.get("test");
|
|
||||||
expect(result).toStrictEqual(testObj);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("not in cache", () => {
|
it("returns a decrypted value when one is stored in local storage", async () => {
|
||||||
const session = { test: stringifiedTestObj };
|
const encrypted = makeEncString("encrypted");
|
||||||
|
localStorage.internalStore["session_test"] = encrypted.encryptedString;
|
||||||
|
encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted"));
|
||||||
|
const result = await sut.get("test");
|
||||||
|
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encrypted, sessionKey);
|
||||||
|
expect(result).toEqual("decrypted");
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
it("caches the decrypted value when one is stored in local storage", async () => {
|
||||||
mockExistingSessionKey(key);
|
const encrypted = makeEncString("encrypted");
|
||||||
});
|
localStorage.internalStore["session_test"] = encrypted.encryptedString;
|
||||||
|
encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted"));
|
||||||
|
await sut.get("test");
|
||||||
|
expect(sut["cache"]["test"]).toEqual("decrypted");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("no session retrieved", () => {
|
describe("getBypassCache", () => {
|
||||||
let result: any;
|
it("ignores cached values", async () => {
|
||||||
let spy: jest.SpyInstance;
|
sut["cache"]["test"] = "cached";
|
||||||
beforeEach(async () => {
|
const encrypted = makeEncString("encrypted");
|
||||||
spy = jest.spyOn(sut, "getLocalSession").mockResolvedValue(null);
|
localStorage.internalStore["session_test"] = encrypted.encryptedString;
|
||||||
localStorageService.get.mockResolvedValue(null);
|
encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted"));
|
||||||
result = await sut.get("test");
|
const result = await sut.getBypassCache("test");
|
||||||
});
|
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encrypted, sessionKey);
|
||||||
|
expect(result).toEqual("decrypted");
|
||||||
|
});
|
||||||
|
|
||||||
it("should grab from session if not in cache", async () => {
|
it("returns a decrypted value when one is stored in local storage", async () => {
|
||||||
expect(spy).toHaveBeenCalledWith(key);
|
const encrypted = makeEncString("encrypted");
|
||||||
});
|
localStorage.internalStore["session_test"] = encrypted.encryptedString;
|
||||||
|
encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted"));
|
||||||
|
const result = await sut.getBypassCache("test");
|
||||||
|
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encrypted, sessionKey);
|
||||||
|
expect(result).toEqual("decrypted");
|
||||||
|
});
|
||||||
|
|
||||||
it("should return null if session is null", async () => {
|
it("caches the decrypted value when one is stored in local storage", async () => {
|
||||||
expect(result).toBeNull();
|
const encrypted = makeEncString("encrypted");
|
||||||
});
|
localStorage.internalStore["session_test"] = encrypted.encryptedString;
|
||||||
});
|
encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted"));
|
||||||
|
await sut.getBypassCache("test");
|
||||||
|
expect(sut["cache"]["test"]).toEqual("decrypted");
|
||||||
|
});
|
||||||
|
|
||||||
describe("session retrieved from storage", () => {
|
it("deserializes when a deserializer is provided", async () => {
|
||||||
beforeEach(() => {
|
const encrypted = makeEncString("encrypted");
|
||||||
jest.spyOn(sut, "getLocalSession").mockResolvedValue(session);
|
localStorage.internalStore["session_test"] = encrypted.encryptedString;
|
||||||
});
|
encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted"));
|
||||||
|
const deserializer = jest.fn().mockReturnValue("deserialized");
|
||||||
it("should return null if session does not have the key", async () => {
|
const result = await sut.getBypassCache("test", { deserializer });
|
||||||
const result = await sut.get("DNE");
|
expect(deserializer).toHaveBeenCalledWith("decrypted");
|
||||||
expect(result).toBeNull();
|
expect(result).toEqual("deserialized");
|
||||||
});
|
|
||||||
|
|
||||||
it("should return the value retrieved from session", async () => {
|
|
||||||
const result = await sut.get("test");
|
|
||||||
expect(result).toEqual(session.test);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should set retrieved values in cache", async () => {
|
|
||||||
await sut.get("test");
|
|
||||||
expect(cache["test"]).toBeTruthy();
|
|
||||||
expect(cache["test"]).toEqual(session.test);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use a deserializer if provided", async () => {
|
|
||||||
const deserializer = jest.fn().mockReturnValue(testObj);
|
|
||||||
const result = await sut.get("test", { deserializer: deserializer });
|
|
||||||
expect(deserializer).toHaveBeenCalledWith(session.test);
|
|
||||||
expect(result).toEqual(testObj);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("has", () => {
|
describe("has", () => {
|
||||||
it("should be false if `get` returns null", async () => {
|
it("returns false when the key is not in cache", async () => {
|
||||||
const spy = jest.spyOn(sut, "get");
|
const result = await sut.has("test");
|
||||||
spy.mockResolvedValue(null);
|
expect(result).toBe(false);
|
||||||
expect(await sut.has("test")).toBe(false);
|
});
|
||||||
|
|
||||||
|
it("returns true when the key is in cache", async () => {
|
||||||
|
sut["cache"]["test"] = "cached";
|
||||||
|
const result = await sut.has("test");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when the key is in local storage", async () => {
|
||||||
|
localStorage.internalStore["session_test"] = makeEncString("encrypted").encryptedString;
|
||||||
|
encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted"));
|
||||||
|
const result = await sut.has("test");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, undefined])("returns false when %s is cached", async (nullish) => {
|
||||||
|
sut["cache"]["test"] = nullish;
|
||||||
|
await expect(sut.has("test")).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, undefined])(
|
||||||
|
"returns false when null is stored in local storage",
|
||||||
|
async (nullish) => {
|
||||||
|
localStorage.internalStore["session_test"] = nullish;
|
||||||
|
await expect(sut.has("test")).resolves.toBe(false);
|
||||||
|
expect(encryptService.decryptToUtf8).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("save", () => {
|
||||||
|
const encString = makeEncString("encrypted");
|
||||||
|
beforeEach(() => {
|
||||||
|
encryptService.encrypt.mockResolvedValue(encString);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs a warning when saving the same value twice and in a dev environment", async () => {
|
||||||
|
platformUtilsService.isDev.mockReturnValue(true);
|
||||||
|
sut["cache"]["test"] = "cached";
|
||||||
|
await sut.save("test", "cached");
|
||||||
|
expect(logService.warning).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not log when saving the same value twice and not in a dev environment", async () => {
|
||||||
|
platformUtilsService.isDev.mockReturnValue(false);
|
||||||
|
sut["cache"]["test"] = "cached";
|
||||||
|
await sut.save("test", "cached");
|
||||||
|
expect(logService.warning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the key when saving a null value", async () => {
|
||||||
|
const spy = jest.spyOn(sut, "remove");
|
||||||
|
await sut.save("test", null);
|
||||||
expect(spy).toHaveBeenCalledWith("test");
|
expect(spy).toHaveBeenCalledWith("test");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be true if `get` returns non-null", async () => {
|
it("saves the value to cache", async () => {
|
||||||
const spy = jest.spyOn(sut, "get");
|
await sut.save("test", "value");
|
||||||
spy.mockResolvedValue({});
|
expect(sut["cache"]["test"]).toEqual("value");
|
||||||
expect(await sut.has("test")).toBe(true);
|
});
|
||||||
expect(spy).toHaveBeenCalledWith("test");
|
|
||||||
|
it("encrypts and saves the value to local storage", async () => {
|
||||||
|
await sut.save("test", "value");
|
||||||
|
expect(encryptService.encrypt).toHaveBeenCalledWith(JSON.stringify("value"), sessionKey);
|
||||||
|
expect(localStorage.internalStore["session_test"]).toEqual(encString.encryptedString);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits an update", async () => {
|
||||||
|
const spy = jest.spyOn(sut["updatesSubject"], "next");
|
||||||
|
await sut.save("test", "value");
|
||||||
|
expect(spy).toHaveBeenCalledWith({ key: "test", updateType: "save" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("remove", () => {
|
describe("remove", () => {
|
||||||
describe("existing cache value is null", () => {
|
it("nulls the value in cache", async () => {
|
||||||
it("should not save null if the local cached value is already null", async () => {
|
sut["cache"]["test"] = "cached";
|
||||||
cache["test"] = null;
|
|
||||||
await sut.remove("test");
|
|
||||||
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not save null if the externally cached value is already null", async () => {
|
|
||||||
sendMessageWithResponseSpy.mockResolvedValue(null);
|
|
||||||
await sut.remove("test");
|
|
||||||
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should save null", async () => {
|
|
||||||
cache["test"] = stringifiedTestObj;
|
|
||||||
|
|
||||||
await sut.remove("test");
|
await sut.remove("test");
|
||||||
expect(sendUpdateSpy).toHaveBeenCalledWith({ key: "test", updateType: "remove" });
|
expect(sut["cache"]["test"]).toBeNull();
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("save", () => {
|
|
||||||
describe("currently cached", () => {
|
|
||||||
it("does not save the value a local cached value exists which is an exact match", async () => {
|
|
||||||
cache["test"] = stringifiedTestObj;
|
|
||||||
await sut.save("test", testObj);
|
|
||||||
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not save the value if a local cached value exists, even if the keys not in the same order", async () => {
|
|
||||||
cache["test"] = JSON.stringify({ b: 2, a: 1 });
|
|
||||||
await sut.save("test", testObj);
|
|
||||||
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not save the value a externally cached value exists which is an exact match", async () => {
|
|
||||||
sendMessageWithResponseSpy.mockResolvedValue(stringifiedTestObj);
|
|
||||||
await sut.save("test", testObj);
|
|
||||||
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
|
||||||
expect(cache["test"]).toBe(stringifiedTestObj);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves the value if the currently cached string value evaluates to a falsy value", async () => {
|
|
||||||
cache["test"] = "null";
|
|
||||||
await sut.save("test", testObj);
|
|
||||||
expect(sendUpdateSpy).toHaveBeenCalledWith({ key: "test", updateType: "save" });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("caching", () => {
|
it("removes the key from local storage", async () => {
|
||||||
beforeEach(() => {
|
localStorage.internalStore["session_test"] = makeEncString("encrypted").encryptedString;
|
||||||
localStorageService.get.mockResolvedValue(null);
|
await sut.remove("test");
|
||||||
sessionStorageService.get.mockResolvedValue(null);
|
expect(localStorage.internalStore["session_test"]).toBeUndefined();
|
||||||
|
|
||||||
localStorageService.save.mockResolvedValue();
|
|
||||||
sessionStorageService.save.mockResolvedValue();
|
|
||||||
|
|
||||||
encryptService.encrypt.mockResolvedValue(mockEnc("{}"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove key from cache if value is null", async () => {
|
|
||||||
cache["test"] = {};
|
|
||||||
// const cacheSetSpy = jest.spyOn(cache, "set");
|
|
||||||
expect(cache["test"]).toBe(true);
|
|
||||||
await sut.save("test", null);
|
|
||||||
// Don't remove from cache, just replace with null
|
|
||||||
expect(cache["test"]).toBe(null);
|
|
||||||
// expect(cacheSetSpy).toHaveBeenCalledWith("test", null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should set cache if value is non-null", async () => {
|
|
||||||
expect(cache["test"]).toBe(false);
|
|
||||||
// const setSpy = jest.spyOn(cache, "set");
|
|
||||||
await sut.save("test", testObj);
|
|
||||||
expect(cache["test"]).toBe(stringifiedTestObj);
|
|
||||||
// expect(setSpy).toHaveBeenCalledWith("test", stringifiedTestObj);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("local storing", () => {
|
it("emits an update", async () => {
|
||||||
let setSpy: jest.SpyInstance;
|
const spy = jest.spyOn(sut["updatesSubject"], "next");
|
||||||
|
await sut.remove("test");
|
||||||
beforeEach(() => {
|
expect(spy).toHaveBeenCalledWith({ key: "test", updateType: "remove" });
|
||||||
setSpy = jest.spyOn(sut, "setLocalSession").mockResolvedValue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should store a new session", async () => {
|
|
||||||
jest.spyOn(sut, "getLocalSession").mockResolvedValue(null);
|
|
||||||
await sut.save("test", testObj);
|
|
||||||
|
|
||||||
expect(setSpy).toHaveBeenCalledWith({ test: testObj }, key);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update an existing session", async () => {
|
|
||||||
const existingObj = { test: testObj };
|
|
||||||
jest.spyOn(sut, "getLocalSession").mockResolvedValue(existingObj);
|
|
||||||
await sut.save("test2", testObj);
|
|
||||||
|
|
||||||
expect(setSpy).toHaveBeenCalledWith({ test2: testObj, ...existingObj }, key);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should overwrite an existing item in session", async () => {
|
|
||||||
const existingObj = { test: {} };
|
|
||||||
jest.spyOn(sut, "getLocalSession").mockResolvedValue(existingObj);
|
|
||||||
await sut.save("test", testObj);
|
|
||||||
|
|
||||||
expect(setSpy).toHaveBeenCalledWith({ test: testObj }, key);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getSessionKey", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
getSessionKeySpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return the stored symmetric crypto key", async () => {
|
|
||||||
sessionStorageService.get.mockResolvedValue({ ...key });
|
|
||||||
const result = await sut.getSessionEncKey();
|
|
||||||
|
|
||||||
expect(result).toStrictEqual(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("new key creation", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
keyGenerationService.createKeyWithPurpose.mockResolvedValue({
|
|
||||||
salt: "salt",
|
|
||||||
material: null,
|
|
||||||
derivedKey: key,
|
|
||||||
});
|
|
||||||
jest.spyOn(sut, "setSessionEncKey").mockResolvedValue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a symmetric crypto key", async () => {
|
|
||||||
const result = await sut.getSessionEncKey();
|
|
||||||
|
|
||||||
expect(result).toStrictEqual(key);
|
|
||||||
expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should store a symmetric crypto key if it makes one", async () => {
|
|
||||||
const spy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue();
|
|
||||||
await sut.getSessionEncKey();
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledWith(key);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getLocalSession", () => {
|
|
||||||
it("should return null if session is null", async () => {
|
|
||||||
const result = await sut.getLocalSession(key);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(localStorageService.get).toHaveBeenCalledWith("session_test");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("non-null sessions", () => {
|
|
||||||
const session = { test: "test" };
|
|
||||||
const encSession = new EncString(JSON.stringify(session));
|
|
||||||
const decryptedSession = JSON.stringify(session);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorageService.get.mockResolvedValue(encSession.encryptedString);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should decrypt returned sessions", async () => {
|
|
||||||
encryptService.decryptToUtf8
|
|
||||||
.calledWith(expect.anything(), key)
|
|
||||||
.mockResolvedValue(decryptedSession);
|
|
||||||
await sut.getLocalSession(key);
|
|
||||||
expect(encryptService.decryptToUtf8).toHaveBeenNthCalledWith(1, encSession, key);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse session", async () => {
|
|
||||||
encryptService.decryptToUtf8
|
|
||||||
.calledWith(expect.anything(), key)
|
|
||||||
.mockResolvedValue(decryptedSession);
|
|
||||||
const result = await sut.getLocalSession(key);
|
|
||||||
expect(result).toEqual(session);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove state if decryption fails", async () => {
|
|
||||||
encryptService.decryptToUtf8.mockResolvedValue(null);
|
|
||||||
const setSessionEncKeySpy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue();
|
|
||||||
|
|
||||||
const result = await sut.getLocalSession(key);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(setSessionEncKeySpy).toHaveBeenCalledWith(null);
|
|
||||||
expect(localStorageService.remove).toHaveBeenCalledWith("session_test");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setLocalSession", () => {
|
|
||||||
const testSession = { test: "a" };
|
|
||||||
const testJSON = JSON.stringify(testSession);
|
|
||||||
|
|
||||||
it("should encrypt a stringified session", async () => {
|
|
||||||
encryptService.encrypt.mockImplementation(mockEnc);
|
|
||||||
localStorageService.save.mockResolvedValue();
|
|
||||||
await sut.setLocalSession(testSession, key);
|
|
||||||
|
|
||||||
expect(encryptService.encrypt).toHaveBeenNthCalledWith(1, testJSON, key);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove local session if null", async () => {
|
|
||||||
encryptService.encrypt.mockResolvedValue(null);
|
|
||||||
await sut.setLocalSession(null, key);
|
|
||||||
|
|
||||||
expect(localStorageService.remove).toHaveBeenCalledWith("session_test");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should save encrypted string", async () => {
|
|
||||||
encryptService.encrypt.mockImplementation(mockEnc);
|
|
||||||
await sut.setLocalSession(testSession, key);
|
|
||||||
|
|
||||||
expect(localStorageService.save).toHaveBeenCalledWith(
|
|
||||||
"session_test",
|
|
||||||
(await mockEnc(testJSON)).encryptedString,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setSessionKey", () => {
|
|
||||||
it("should remove if null", async () => {
|
|
||||||
await sut.setSessionEncKey(null);
|
|
||||||
expect(sessionStorageService.remove).toHaveBeenCalledWith("localEncryptionKey_test");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should save key when not null", async () => {
|
|
||||||
await sut.setSessionEncKey(key);
|
|
||||||
expect(sessionStorageService.save).toHaveBeenCalledWith("localEncryptionKey_test", key);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Subject } from "rxjs";
|
|||||||
import { Jsonify } from "type-fest";
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
|
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import {
|
import {
|
||||||
@@ -11,13 +10,12 @@ import {
|
|||||||
ObservableStorageService,
|
ObservableStorageService,
|
||||||
StorageUpdate,
|
StorageUpdate,
|
||||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||||
|
import { Lazy } from "@bitwarden/common/platform/misc/lazy";
|
||||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||||
import { MemoryStorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
|
import { MemoryStorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
|
|
||||||
import { BrowserApi } from "../browser/browser-api";
|
import { BrowserApi } from "../browser/browser-api";
|
||||||
import { devFlag } from "../decorators/dev-flag.decorator";
|
|
||||||
import { devFlagEnabled } from "../flags";
|
|
||||||
import { MemoryStoragePortMessage } from "../storage/port-messages";
|
import { MemoryStoragePortMessage } from "../storage/port-messages";
|
||||||
import { portName } from "../storage/port-name";
|
import { portName } from "../storage/port-name";
|
||||||
|
|
||||||
@@ -25,85 +23,64 @@ export class LocalBackedSessionStorageService
|
|||||||
extends AbstractMemoryStorageService
|
extends AbstractMemoryStorageService
|
||||||
implements ObservableStorageService
|
implements ObservableStorageService
|
||||||
{
|
{
|
||||||
|
private ports: Set<chrome.runtime.Port> = new Set([]);
|
||||||
|
private cache: Record<string, unknown> = {};
|
||||||
private updatesSubject = new Subject<StorageUpdate>();
|
private updatesSubject = new Subject<StorageUpdate>();
|
||||||
private commandName = `localBackedSessionStorage_${this.partitionName}`;
|
readonly valuesRequireDeserialization = true;
|
||||||
private encKey = `localEncryptionKey_${this.partitionName}`;
|
updates$ = this.updatesSubject.asObservable();
|
||||||
private sessionKey = `session_${this.partitionName}`;
|
|
||||||
private cachedSession: Record<string, unknown> = {};
|
|
||||||
private _ports: Set<chrome.runtime.Port> = new Set([]);
|
|
||||||
private knownNullishCacheKeys: Set<string> = new Set([]);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private logService: LogService,
|
private readonly sessionKey: Lazy<Promise<SymmetricCryptoKey>>,
|
||||||
private encryptService: EncryptService,
|
private readonly localStorage: AbstractStorageService,
|
||||||
private keyGenerationService: KeyGenerationService,
|
private readonly encryptService: EncryptService,
|
||||||
private localStorage: AbstractStorageService,
|
private readonly platformUtilsService: PlatformUtilsService,
|
||||||
private sessionStorage: AbstractStorageService,
|
private readonly logService: LogService,
|
||||||
private platformUtilsService: PlatformUtilsService,
|
|
||||||
private partitionName: string,
|
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
|
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
|
||||||
if (port.name !== `${portName(chrome.storage.session)}_${partitionName}`) {
|
if (port.name !== portName(chrome.storage.session)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._ports.add(port);
|
this.ports.add(port);
|
||||||
|
|
||||||
const listenerCallback = this.onMessageFromForeground.bind(this);
|
const listenerCallback = this.onMessageFromForeground.bind(this);
|
||||||
port.onDisconnect.addListener(() => {
|
port.onDisconnect.addListener(() => {
|
||||||
this._ports.delete(port);
|
this.ports.delete(port);
|
||||||
port.onMessage.removeListener(listenerCallback);
|
port.onMessage.removeListener(listenerCallback);
|
||||||
});
|
});
|
||||||
port.onMessage.addListener(listenerCallback);
|
port.onMessage.addListener(listenerCallback);
|
||||||
// Initialize the new memory storage service with existing data
|
// Initialize the new memory storage service with existing data
|
||||||
this.sendMessageTo(port, {
|
this.sendMessageTo(port, {
|
||||||
action: "initialization",
|
action: "initialization",
|
||||||
data: Array.from(Object.keys(this.cachedSession)),
|
data: Array.from(Object.keys(this.cache)),
|
||||||
|
});
|
||||||
|
this.updates$.subscribe((update) => {
|
||||||
|
this.broadcastMessage({
|
||||||
|
action: "subject_update",
|
||||||
|
data: update,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.updates$.subscribe((update) => {
|
|
||||||
this.broadcastMessage({
|
|
||||||
action: "subject_update",
|
|
||||||
data: update,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get valuesRequireDeserialization(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
get updates$() {
|
|
||||||
return this.updatesSubject.asObservable();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> {
|
async get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> {
|
||||||
if (this.cachedSession[key] != null) {
|
if (this.cache[key] !== undefined) {
|
||||||
return this.cachedSession[key] as T;
|
return this.cache[key] as T;
|
||||||
}
|
|
||||||
|
|
||||||
if (this.knownNullishCacheKeys.has(key)) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.getBypassCache(key, options);
|
return await this.getBypassCache(key, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBypassCache<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> {
|
async getBypassCache<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> {
|
||||||
const session = await this.getLocalSession(await this.getSessionEncKey());
|
let value = await this.getLocalSessionValue(await this.sessionKey.get(), key);
|
||||||
if (session[key] == null) {
|
|
||||||
this.knownNullishCacheKeys.add(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let value = session[key];
|
|
||||||
if (options?.deserializer != null) {
|
if (options?.deserializer != null) {
|
||||||
value = options.deserializer(value as Jsonify<T>);
|
value = options.deserializer(value as Jsonify<T>);
|
||||||
}
|
}
|
||||||
|
|
||||||
void this.save(key, value);
|
this.cache[key] = value;
|
||||||
return value as T;
|
return value as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +91,7 @@ export class LocalBackedSessionStorageService
|
|||||||
async save<T>(key: string, obj: T): Promise<void> {
|
async save<T>(key: string, obj: T): Promise<void> {
|
||||||
// This is for observation purposes only. At some point, we don't want to write to local session storage if the value is the same.
|
// This is for observation purposes only. At some point, we don't want to write to local session storage if the value is the same.
|
||||||
if (this.platformUtilsService.isDev()) {
|
if (this.platformUtilsService.isDev()) {
|
||||||
const existingValue = this.cachedSession[key] as T;
|
const existingValue = this.cache[key] as T;
|
||||||
if (this.compareValues<T>(existingValue, obj)) {
|
if (this.compareValues<T>(existingValue, obj)) {
|
||||||
this.logService.warning(`Possible unnecessary write to local session storage. Key: ${key}`);
|
this.logService.warning(`Possible unnecessary write to local session storage. Key: ${key}`);
|
||||||
this.logService.warning(obj as any);
|
this.logService.warning(obj as any);
|
||||||
@@ -125,128 +102,42 @@ export class LocalBackedSessionStorageService
|
|||||||
return await this.remove(key);
|
return await this.remove(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.knownNullishCacheKeys.delete(key);
|
this.cache[key] = obj;
|
||||||
this.cachedSession[key] = obj;
|
|
||||||
await this.updateLocalSessionValue(key, obj);
|
await this.updateLocalSessionValue(key, obj);
|
||||||
this.updatesSubject.next({ key, updateType: "save" });
|
this.updatesSubject.next({ key, updateType: "save" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(key: string): Promise<void> {
|
async remove(key: string): Promise<void> {
|
||||||
this.knownNullishCacheKeys.add(key);
|
this.cache[key] = null;
|
||||||
delete this.cachedSession[key];
|
|
||||||
await this.updateLocalSessionValue(key, null);
|
await this.updateLocalSessionValue(key, null);
|
||||||
this.updatesSubject.next({ key, updateType: "remove" });
|
this.updatesSubject.next({ key, updateType: "remove" });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateLocalSessionValue<T>(key: string, obj: T) {
|
private async getLocalSessionValue(encKey: SymmetricCryptoKey, key: string): Promise<unknown> {
|
||||||
const sessionEncKey = await this.getSessionEncKey();
|
const local = await this.localStorage.get<string>(this.sessionStorageKey(key));
|
||||||
const localSession = (await this.getLocalSession(sessionEncKey)) ?? {};
|
|
||||||
localSession[key] = obj;
|
|
||||||
void this.setLocalSession(localSession, sessionEncKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLocalSession(encKey: SymmetricCryptoKey): Promise<Record<string, unknown>> {
|
|
||||||
if (Object.keys(this.cachedSession).length > 0) {
|
|
||||||
return this.cachedSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cachedSession = {};
|
|
||||||
const local = await this.localStorage.get<string>(this.sessionKey);
|
|
||||||
if (local == null) {
|
if (local == null) {
|
||||||
return this.cachedSession;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (devFlagEnabled("storeSessionDecrypted")) {
|
const valueJson = await this.encryptService.decryptToUtf8(new EncString(local), encKey);
|
||||||
return local as any as Record<string, unknown>;
|
if (valueJson == null) {
|
||||||
|
// error with decryption, value is lost, delete state and start over
|
||||||
|
await this.localStorage.remove(this.sessionStorageKey(key));
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionJson = await this.encryptService.decryptToUtf8(new EncString(local), encKey);
|
return JSON.parse(valueJson);
|
||||||
if (sessionJson == null) {
|
|
||||||
// Error with decryption -- session is lost, delete state and key and start over
|
|
||||||
await this.setSessionEncKey(null);
|
|
||||||
await this.localStorage.remove(this.sessionKey);
|
|
||||||
return this.cachedSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cachedSession = JSON.parse(sessionJson);
|
|
||||||
return this.cachedSession;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setLocalSession(session: Record<string, unknown>, key: SymmetricCryptoKey) {
|
private async updateLocalSessionValue(key: string, value: unknown): Promise<void> {
|
||||||
if (devFlagEnabled("storeSessionDecrypted")) {
|
if (value == null) {
|
||||||
await this.setDecryptedLocalSession(session);
|
await this.localStorage.remove(this.sessionStorageKey(key));
|
||||||
} else {
|
return;
|
||||||
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(this.sessionKey);
|
|
||||||
} else {
|
|
||||||
await this.localStorage.save(this.sessionKey, jsonSession);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async setEncryptedLocalSession(session: Record<string, unknown>, key: SymmetricCryptoKey) {
|
|
||||||
const jsonSession = JSON.stringify(session);
|
|
||||||
const encSession = await this.encryptService.encrypt(jsonSession, key);
|
|
||||||
|
|
||||||
if (encSession == null) {
|
|
||||||
return await this.localStorage.remove(this.sessionKey);
|
|
||||||
}
|
|
||||||
await this.localStorage.save(this.sessionKey, encSession.encryptedString);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSessionEncKey(): Promise<SymmetricCryptoKey> {
|
|
||||||
let storedKey = await this.sessionStorage.get<SymmetricCryptoKey>(this.encKey);
|
|
||||||
if (storedKey == null || Object.keys(storedKey).length == 0) {
|
|
||||||
const generatedKey = await this.keyGenerationService.createKeyWithPurpose(
|
|
||||||
128,
|
|
||||||
"ephemeral",
|
|
||||||
"bitwarden-ephemeral",
|
|
||||||
);
|
|
||||||
storedKey = generatedKey.derivedKey;
|
|
||||||
await this.setSessionEncKey(storedKey);
|
|
||||||
return storedKey;
|
|
||||||
} else {
|
|
||||||
return SymmetricCryptoKey.fromJSON(storedKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async setSessionEncKey(input: SymmetricCryptoKey): Promise<void> {
|
|
||||||
if (input == null) {
|
|
||||||
await this.sessionStorage.remove(this.encKey);
|
|
||||||
} else {
|
|
||||||
await this.sessionStorage.save(this.encKey, input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private compareValues<T>(value1: T, value2: T): boolean {
|
|
||||||
if (value1 == null && value2 == null) {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value1 && value2 == null) {
|
const valueJson = JSON.stringify(value);
|
||||||
return false;
|
const encValue = await this.encryptService.encrypt(valueJson, await this.sessionKey.get());
|
||||||
}
|
await this.localStorage.save(this.sessionStorageKey(key), encValue.encryptedString);
|
||||||
|
|
||||||
if (value1 == null && value2) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value1 !== "object" || typeof value2 !== "object") {
|
|
||||||
return value1 === value2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (JSON.stringify(value1) === JSON.stringify(value2)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(value1).sort().toString() === Object.entries(value2).sort().toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onMessageFromForeground(
|
private async onMessageFromForeground(
|
||||||
@@ -282,7 +173,7 @@ export class LocalBackedSessionStorageService
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected broadcastMessage(data: Omit<MemoryStoragePortMessage, "originator">) {
|
protected broadcastMessage(data: Omit<MemoryStoragePortMessage, "originator">) {
|
||||||
this._ports.forEach((port) => {
|
this.ports.forEach((port) => {
|
||||||
this.sendMessageTo(port, data);
|
this.sendMessageTo(port, data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -296,4 +187,32 @@ export class LocalBackedSessionStorageService
|
|||||||
originator: "background",
|
originator: "background",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sessionStorageKey(key: string) {
|
||||||
|
return `session_${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private compareValues<T>(value1: T, value2: T): boolean {
|
||||||
|
if (value1 == null && value2 == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value1 && value2 == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value1 == null && value2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value1 !== "object" || typeof value2 !== "object") {
|
||||||
|
return value1 === value2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JSON.stringify(value1) === JSON.stringify(value2)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(value1).sort().toString() === Object.entries(value2).sort().toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from "./matchers";
|
|||||||
export * from "./fake-state-provider";
|
export * from "./fake-state-provider";
|
||||||
export * from "./fake-state";
|
export * from "./fake-state";
|
||||||
export * from "./fake-account-service";
|
export * from "./fake-account-service";
|
||||||
|
export * from "./fake-storage.service";
|
||||||
|
|||||||
85
libs/common/src/platform/misc/lazy.spec.ts
Normal file
85
libs/common/src/platform/misc/lazy.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Lazy } from "./lazy";
|
||||||
|
|
||||||
|
describe("Lazy", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("async", () => {
|
||||||
|
let factory: jest.Mock<Promise<number>>;
|
||||||
|
let lazy: Lazy<Promise<number>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
factory = jest.fn();
|
||||||
|
lazy = new Lazy(factory);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("get", () => {
|
||||||
|
it("should call the factory once", async () => {
|
||||||
|
await lazy.get();
|
||||||
|
await lazy.get();
|
||||||
|
|
||||||
|
expect(factory).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the value from the factory", async () => {
|
||||||
|
factory.mockResolvedValue(42);
|
||||||
|
|
||||||
|
const value = await lazy.get();
|
||||||
|
|
||||||
|
expect(value).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("factory throws", () => {
|
||||||
|
it("should throw the error", async () => {
|
||||||
|
factory.mockRejectedValue(new Error("factory error"));
|
||||||
|
|
||||||
|
await expect(lazy.get()).rejects.toThrow("factory error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("factory returns undefined", () => {
|
||||||
|
it("should return undefined", async () => {
|
||||||
|
factory.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const value = await lazy.get();
|
||||||
|
|
||||||
|
expect(value).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("factory returns null", () => {
|
||||||
|
it("should return null", async () => {
|
||||||
|
factory.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const value = await lazy.get();
|
||||||
|
|
||||||
|
expect(value).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sync", () => {
|
||||||
|
const syncFactory = jest.fn();
|
||||||
|
let lazy: Lazy<number>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
syncFactory.mockReturnValue(42);
|
||||||
|
lazy = new Lazy<number>(syncFactory);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the value from the factory", () => {
|
||||||
|
const value = lazy.get();
|
||||||
|
|
||||||
|
expect(value).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call the factory once", () => {
|
||||||
|
lazy.get();
|
||||||
|
lazy.get();
|
||||||
|
|
||||||
|
expect(syncFactory).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
20
libs/common/src/platform/misc/lazy.ts
Normal file
20
libs/common/src/platform/misc/lazy.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export class Lazy<T> {
|
||||||
|
private _value: T | undefined = undefined;
|
||||||
|
private _isCreated = false;
|
||||||
|
|
||||||
|
constructor(private readonly factory: () => T) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the factory and returns the result. Guaranteed to resolve the value only once.
|
||||||
|
*
|
||||||
|
* @returns The value produced by your factory.
|
||||||
|
*/
|
||||||
|
get(): T {
|
||||||
|
if (!this._isCreated) {
|
||||||
|
this._value = this.factory();
|
||||||
|
this._isCreated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._value as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user