1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 06:54:07 +00:00

Merge branch 'main' into ps/extension-refresh

This commit is contained in:
Victoria League
2024-04-23 09:52:28 -04:00
committed by GitHub
47 changed files with 627 additions and 656 deletions

View File

@@ -5,5 +5,6 @@
"**/locales/*[^n]/messages.json": true,
"**/_locales/[^e]*/messages.json": true,
"**/_locales/*[^n]/messages.json": true
}
},
"rust-analyzer.linkedProjects": ["apps/desktop/desktop_native/Cargo.toml"]
}

View File

@@ -1,6 +1,5 @@
{
"devFlags": {
"storeSessionDecrypted": false,
"managedEnvironment": {
"base": "https://localhost:8080"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2024.4.1",
"version": "2024.4.2",
"scripts": {
"build": "webpack",
"build:mv3": "cross-env MANIFEST_VERSION=3 webpack",

View File

@@ -98,7 +98,9 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
// eslint-disable-next-line no-restricted-imports -- Used for dependency creation
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 { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
@@ -111,7 +113,6 @@ import { KeyGenerationService } from "@bitwarden/common/platform/services/key-ge
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { SystemService } from "@bitwarden/common/platform/services/system.service";
import { UserKeyInitService } from "@bitwarden/common/platform/services/user-key-init.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
@@ -226,6 +227,7 @@ import { BackgroundPlatformUtilsService } from "../platform/services/platform-ut
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
import { BackgroundDerivedStateProvider } from "../platform/state/background-derived-state.provider";
import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service";
import { BrowserStorageServiceProvider } from "../platform/storage/browser-storage-service.provider";
import { ForegroundMemoryStorageService } from "../platform/storage/foreground-memory-storage.service";
import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging";
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
@@ -246,6 +248,8 @@ export default class MainBackground {
secureStorageService: AbstractStorageService;
memoryStorageService: AbstractMemoryStorageService;
memoryStorageForStateProviders: AbstractMemoryStorageService & ObservableStorageService;
largeObjectMemoryStorageForStateProviders: AbstractMemoryStorageService &
ObservableStorageService;
i18nService: I18nServiceAbstraction;
platformUtilsService: PlatformUtilsServiceAbstraction;
logService: LogServiceAbstraction;
@@ -402,34 +406,57 @@ export default class MainBackground {
self,
);
const mv3MemoryStorageCreator = (partitionName: string) => {
if (this.popupOnlyContext) {
return new ForegroundMemoryStorageService(partitionName);
// Creates a session key for mv3 storage of large memory items
const sessionKey = new Lazy(async () => {
// 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(
this.logService,
sessionKey,
this.storageService,
new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false),
this.keyGenerationService,
new BrowserLocalStorageService(),
new BrowserMemoryStorageService(),
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.memoryStorageService = BrowserApi.isManifestVersion(3)
? mv3MemoryStorageCreator("stateService")
: new MemoryStorageService();
this.memoryStorageForStateProviders = BrowserApi.isManifestVersion(3)
? mv3MemoryStorageCreator("stateProviders")
: new BackgroundMemoryStorageService();
? new BrowserMemoryStorageService() // mv3 stores to storage.session
: 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)
? mv3MemoryStorageCreator() // mv3 stores to local-backed session storage
: this.memoryStorageForStateProviders; // mv2 stores to the same location
const storageServiceProvider = new StorageServiceProvider(
const storageServiceProvider = new BrowserStorageServiceProvider(
this.storageService,
this.memoryStorageForStateProviders,
this.largeObjectMemoryStorageForStateProviders,
);
this.globalStateProvider = new DefaultGlobalStateProvider(storageServiceProvider);
@@ -466,9 +493,7 @@ export default class MainBackground {
this.accountService,
this.singleUserStateProvider,
);
this.derivedStateProvider = new BackgroundDerivedStateProvider(
this.memoryStorageForStateProviders,
);
this.derivedStateProvider = new BackgroundDerivedStateProvider(storageServiceProvider);
this.stateProvider = new DefaultStateProvider(
this.activeUserStateProvider,
this.singleUserStateProvider,
@@ -1080,20 +1105,22 @@ export default class MainBackground {
await (this.eventUploadService as EventUploadService).init(true);
this.twoFactorService.init();
if (!this.popupOnlyContext) {
await this.vaultTimeoutService.init(true);
this.fido2Background.init();
await this.runtimeBackground.init();
await this.notificationBackground.init();
this.filelessImporterBackground.init();
await this.commandsBackground.init();
await this.overlayBackground.init();
await this.tabsBackground.init();
this.contextMenusBackground?.init();
await this.idleBackground.init();
if (BrowserApi.isManifestVersion(2)) {
await this.webRequestBackground.init();
}
if (this.popupOnlyContext) {
return;
}
await this.vaultTimeoutService.init(true);
this.fido2Background.init();
await this.runtimeBackground.init();
await this.notificationBackground.init();
this.filelessImporterBackground.init();
await this.commandsBackground.init();
await this.overlayBackground.init();
await this.tabsBackground.init();
this.contextMenusBackground?.init();
await this.idleBackground.init();
if (BrowserApi.isManifestVersion(2)) {
await this.webRequestBackground.init();
}
if (this.platformUtilsService.isFirefox() && !this.isPrivateMode) {

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2024.4.1",
"version": "2024.4.2",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2024.4.1",
"version": "2024.4.2",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",
@@ -59,7 +59,6 @@
"clipboardRead",
"clipboardWrite",
"idle",
"alarms",
"scripting",
"offscreen"
],

View File

@@ -4,14 +4,14 @@ import { BackgroundDerivedStateProvider } from "../../state/background-derived-s
import { CachedServices, FactoryOptions, factory } from "./factory-options";
import {
MemoryStorageServiceInitOptions,
observableMemoryStorageServiceFactory,
} from "./storage-service.factory";
StorageServiceProviderInitOptions,
storageServiceProviderFactory,
} from "./storage-service-provider.factory";
type DerivedStateProviderFactoryOptions = FactoryOptions;
export type DerivedStateProviderInitOptions = DerivedStateProviderFactoryOptions &
MemoryStorageServiceInitOptions;
StorageServiceProviderInitOptions;
export async function derivedStateProviderFactory(
cache: { derivedStateProvider?: DerivedStateProvider } & CachedServices,
@@ -22,6 +22,6 @@ export async function derivedStateProviderFactory(
"derivedStateProvider",
opts,
async () =>
new BackgroundDerivedStateProvider(await observableMemoryStorageServiceFactory(cache, opts)),
new BackgroundDerivedStateProvider(await storageServiceProviderFactory(cache, opts)),
);
}

View File

@@ -3,6 +3,8 @@ import {
AbstractStorageService,
ObservableStorageService,
} 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 { BrowserApi } from "../../browser/browser-api";
@@ -17,10 +19,10 @@ import {
KeyGenerationServiceInitOptions,
keyGenerationServiceFactory,
} from "./key-generation-service.factory";
import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory";
import { LogServiceInitOptions, logServiceFactory } from "./log-service.factory";
import {
platformUtilsServiceFactory,
PlatformUtilsServiceInitOptions,
platformUtilsServiceFactory,
} from "./platform-utils-service.factory";
export type DiskStorageServiceInitOptions = FactoryOptions;
@@ -70,13 +72,23 @@ export function memoryStorageServiceFactory(
return factory(cache, "memoryStorageService", opts, async () => {
if (BrowserApi.isManifestVersion(3)) {
return new LocalBackedSessionStorageService(
await logServiceFactory(cache, opts),
await encryptServiceFactory(cache, opts),
await keyGenerationServiceFactory(cache, opts),
new Lazy(async () => {
const existingKey = await (
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 sessionStorageServiceFactory(cache, opts),
await encryptServiceFactory(cache, opts),
await platformUtilsServiceFactory(cache, opts),
"serviceFactories",
await logServiceFactory(cache, opts),
);
}
return new MemoryStorageService();

View File

@@ -9,7 +9,7 @@ jest.mock("../flags", () => ({
}));
class TestClass {
@devFlag("storeSessionDecrypted") test() {
@devFlag("managedEnvironment") test() {
return "test";
}
}

View File

@@ -19,7 +19,6 @@ export type Flags = {
// required to avoid linting errors when there are no flags
// eslint-disable-next-line @typescript-eslint/ban-types
export type DevFlags = {
storeSessionDecrypted?: boolean;
managedEnvironment?: GroupPolicyEnvironment;
} & SharedDevFlags;

View File

@@ -1,7 +1,16 @@
import { AbstractMemoryStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import AbstractChromeStorageService from "./abstractions/abstract-chrome-storage-api.service";
export default class BrowserMemoryStorageService extends AbstractChromeStorageService {
export default class BrowserMemoryStorageService
extends AbstractChromeStorageService
implements AbstractMemoryStorageService
{
constructor() {
super(chrome.storage.session);
}
type = "MemoryStorageService" as const;
getBypassCache<T>(key: string): Promise<T> {
return this.get(key);
}
}

View File

@@ -1,412 +1,200 @@
import { mock, MockProxy } from "jest-mock-extended";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
AbstractMemoryStorageService,
AbstractStorageService,
StorageUpdate,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { Lazy } from "@bitwarden/common/platform/misc/lazy";
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 { BrowserApi } from "../browser/browser-api";
import { FakeStorageService, makeEncString } from "@bitwarden/common/spec";
import { LocalBackedSessionStorageService } from "./local-backed-session-storage.service";
describe.skip("LocalBackedSessionStorage", () => {
const sendMessageWithResponseSpy: jest.SpyInstance = jest.spyOn(
BrowserApi,
"sendMessageWithResponse",
describe("LocalBackedSessionStorage", () => {
const sessionKey = new SymmetricCryptoKey(
Utils.fromUtf8ToArray("00000000000000000000000000000000"),
);
let localStorage: FakeStorageService;
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 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 logService: MockProxy<LogService>;
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(() => {
sendMessageWithResponseSpy.mockResolvedValue(null);
logService = mock<LogService>();
localStorage = new FakeStorageService();
encryptService = mock<EncryptService>();
keyGenerationService = mock<KeyGenerationService>();
localStorageService = mock<AbstractStorageService>();
sessionStorageService = mock<AbstractMemoryStorageService>();
platformUtilsService = mock<PlatformUtilsService>();
logService = mock<LogService>();
sut = new LocalBackedSessionStorageService(
logService,
new Lazy(async () => sessionKey),
localStorage,
encryptService,
keyGenerationService,
localStorageService,
sessionStorageService,
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("in local cache or external context cache", () => {
it("should return from local cache", async () => {
cache["test"] = stringifiedTestObj;
const result = await sut.get("test");
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);
});
it("return the cached value when one is cached", async () => {
sut["cache"]["test"] = "cached";
const result = await sut.get("test");
expect(result).toEqual("cached");
});
describe("not in cache", () => {
const session = { test: stringifiedTestObj };
it("returns a decrypted value when one is stored in local storage", async () => {
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(() => {
mockExistingSessionKey(key);
});
it("caches the decrypted value when one is stored in local storage", async () => {
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", () => {
let result: any;
let spy: jest.SpyInstance;
beforeEach(async () => {
spy = jest.spyOn(sut, "getLocalSession").mockResolvedValue(null);
localStorageService.get.mockResolvedValue(null);
result = await sut.get("test");
});
describe("getBypassCache", () => {
it("ignores cached values", async () => {
sut["cache"]["test"] = "cached";
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 grab from session if not in cache", async () => {
expect(spy).toHaveBeenCalledWith(key);
});
it("returns a decrypted value when one is stored in local storage", async () => {
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 () => {
expect(result).toBeNull();
});
});
it("caches the decrypted value when one is stored in local storage", async () => {
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", () => {
beforeEach(() => {
jest.spyOn(sut, "getLocalSession").mockResolvedValue(session);
});
it("should return null if session does not have the key", async () => {
const result = await sut.get("DNE");
expect(result).toBeNull();
});
it("should return the value retrieved from session", async () => {
const result = await sut.get("test");
expect(result).toEqual(session.test);
});
it("should set retrieved values in cache", async () => {
await sut.get("test");
expect(cache["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);
});
});
it("deserializes when a deserializer is provided", async () => {
const encrypted = makeEncString("encrypted");
localStorage.internalStore["session_test"] = encrypted.encryptedString;
encryptService.decryptToUtf8.mockResolvedValue(JSON.stringify("decrypted"));
const deserializer = jest.fn().mockReturnValue("deserialized");
const result = await sut.getBypassCache("test", { deserializer });
expect(deserializer).toHaveBeenCalledWith("decrypted");
expect(result).toEqual("deserialized");
});
});
describe("has", () => {
it("should be false if `get` returns null", async () => {
const spy = jest.spyOn(sut, "get");
spy.mockResolvedValue(null);
expect(await sut.has("test")).toBe(false);
it("returns false when the key is not in cache", async () => {
const result = await sut.has("test");
expect(result).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");
});
it("should be true if `get` returns non-null", async () => {
const spy = jest.spyOn(sut, "get");
spy.mockResolvedValue({});
expect(await sut.has("test")).toBe(true);
expect(spy).toHaveBeenCalledWith("test");
it("saves the value to cache", async () => {
await sut.save("test", "value");
expect(sut["cache"]["test"]).toEqual("value");
});
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("existing cache value is null", () => {
it("should not save null if the local cached value is already null", async () => {
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;
it("nulls the value in cache", async () => {
sut["cache"]["test"] = "cached";
await sut.remove("test");
expect(sendUpdateSpy).toHaveBeenCalledWith({ key: "test", updateType: "remove" });
});
});
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" });
});
expect(sut["cache"]["test"]).toBeNull();
});
describe("caching", () => {
beforeEach(() => {
localStorageService.get.mockResolvedValue(null);
sessionStorageService.get.mockResolvedValue(null);
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);
});
it("removes the key from local storage", async () => {
localStorage.internalStore["session_test"] = makeEncString("encrypted").encryptedString;
await sut.remove("test");
expect(localStorage.internalStore["session_test"]).toBeUndefined();
});
describe("local storing", () => {
let setSpy: jest.SpyInstance;
beforeEach(() => {
setSpy = jest.spyOn(sut, "setLocalSession").mockResolvedValue();
});
it("should store a new session", async () => {
jest.spyOn(sut, "getLocalSession").mockResolvedValue(null);
await sut.save("test", testObj);
expect(setSpy).toHaveBeenCalledWith({ test: testObj }, key);
});
it("should update an existing session", async () => {
const existingObj = { test: testObj };
jest.spyOn(sut, "getLocalSession").mockResolvedValue(existingObj);
await sut.save("test2", testObj);
expect(setSpy).toHaveBeenCalledWith({ test2: testObj, ...existingObj }, key);
});
it("should overwrite an existing item in session", async () => {
const existingObj = { test: {} };
jest.spyOn(sut, "getLocalSession").mockResolvedValue(existingObj);
await sut.save("test", testObj);
expect(setSpy).toHaveBeenCalledWith({ test: testObj }, key);
});
});
});
describe("getSessionKey", () => {
beforeEach(() => {
getSessionKeySpy.mockRestore();
});
it("should return the stored symmetric crypto key", async () => {
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);
it("emits an update", async () => {
const spy = jest.spyOn(sut["updatesSubject"], "next");
await sut.remove("test");
expect(spy).toHaveBeenCalledWith({ key: "test", updateType: "remove" });
});
});
});

View File

@@ -2,7 +2,6 @@ import { Subject } from "rxjs";
import { Jsonify } from "type-fest";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
@@ -11,13 +10,12 @@ import {
ObservableStorageService,
StorageUpdate,
} 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 { MemoryStorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { BrowserApi } from "../browser/browser-api";
import { devFlag } from "../decorators/dev-flag.decorator";
import { devFlagEnabled } from "../flags";
import { MemoryStoragePortMessage } from "../storage/port-messages";
import { portName } from "../storage/port-name";
@@ -25,85 +23,64 @@ export class LocalBackedSessionStorageService
extends AbstractMemoryStorageService
implements ObservableStorageService
{
private ports: Set<chrome.runtime.Port> = new Set([]);
private cache: Record<string, unknown> = {};
private updatesSubject = new Subject<StorageUpdate>();
private commandName = `localBackedSessionStorage_${this.partitionName}`;
private encKey = `localEncryptionKey_${this.partitionName}`;
private sessionKey = `session_${this.partitionName}`;
private cachedSession: Record<string, unknown> = {};
private _ports: Set<chrome.runtime.Port> = new Set([]);
private knownNullishCacheKeys: Set<string> = new Set([]);
readonly valuesRequireDeserialization = true;
updates$ = this.updatesSubject.asObservable();
constructor(
private logService: LogService,
private encryptService: EncryptService,
private keyGenerationService: KeyGenerationService,
private localStorage: AbstractStorageService,
private sessionStorage: AbstractStorageService,
private platformUtilsService: PlatformUtilsService,
private partitionName: string,
private readonly sessionKey: Lazy<Promise<SymmetricCryptoKey>>,
private readonly localStorage: AbstractStorageService,
private readonly encryptService: EncryptService,
private readonly platformUtilsService: PlatformUtilsService,
private readonly logService: LogService,
) {
super();
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
if (port.name !== `${portName(chrome.storage.session)}_${partitionName}`) {
if (port.name !== portName(chrome.storage.session)) {
return;
}
this._ports.add(port);
this.ports.add(port);
const listenerCallback = this.onMessageFromForeground.bind(this);
port.onDisconnect.addListener(() => {
this._ports.delete(port);
this.ports.delete(port);
port.onMessage.removeListener(listenerCallback);
});
port.onMessage.addListener(listenerCallback);
// Initialize the new memory storage service with existing data
this.sendMessageTo(port, {
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> {
if (this.cachedSession[key] != null) {
return this.cachedSession[key] as T;
}
if (this.knownNullishCacheKeys.has(key)) {
return null;
if (this.cache[key] !== undefined) {
return this.cache[key] as T;
}
return await this.getBypassCache(key, options);
}
async getBypassCache<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> {
const session = await this.getLocalSession(await this.getSessionEncKey());
if (session[key] == null) {
this.knownNullishCacheKeys.add(key);
return null;
}
let value = await this.getLocalSessionValue(await this.sessionKey.get(), key);
let value = session[key];
if (options?.deserializer != null) {
value = options.deserializer(value as Jsonify<T>);
}
void this.save(key, value);
this.cache[key] = value;
return value as T;
}
@@ -114,7 +91,7 @@ export class LocalBackedSessionStorageService
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.
if (this.platformUtilsService.isDev()) {
const existingValue = this.cachedSession[key] as T;
const existingValue = this.cache[key] as T;
if (this.compareValues<T>(existingValue, obj)) {
this.logService.warning(`Possible unnecessary write to local session storage. Key: ${key}`);
this.logService.warning(obj as any);
@@ -125,128 +102,42 @@ export class LocalBackedSessionStorageService
return await this.remove(key);
}
this.knownNullishCacheKeys.delete(key);
this.cachedSession[key] = obj;
this.cache[key] = obj;
await this.updateLocalSessionValue(key, obj);
this.updatesSubject.next({ key, updateType: "save" });
}
async remove(key: string): Promise<void> {
this.knownNullishCacheKeys.add(key);
delete this.cachedSession[key];
this.cache[key] = null;
await this.updateLocalSessionValue(key, null);
this.updatesSubject.next({ key, updateType: "remove" });
}
private async updateLocalSessionValue<T>(key: string, obj: T) {
const sessionEncKey = await this.getSessionEncKey();
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);
private async getLocalSessionValue(encKey: SymmetricCryptoKey, key: string): Promise<unknown> {
const local = await this.localStorage.get<string>(this.sessionStorageKey(key));
if (local == null) {
return this.cachedSession;
return null;
}
if (devFlagEnabled("storeSessionDecrypted")) {
return local as any as Record<string, unknown>;
const valueJson = await this.encryptService.decryptToUtf8(new EncString(local), encKey);
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);
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;
return JSON.parse(valueJson);
}
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(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;
private async updateLocalSessionValue(key: string, value: unknown): Promise<void> {
if (value == null) {
await this.localStorage.remove(this.sessionStorageKey(key));
return;
}
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();
const valueJson = JSON.stringify(value);
const encValue = await this.encryptService.encrypt(valueJson, await this.sessionKey.get());
await this.localStorage.save(this.sessionStorageKey(key), encValue.encryptedString);
}
private async onMessageFromForeground(
@@ -282,7 +173,7 @@ export class LocalBackedSessionStorageService
}
protected broadcastMessage(data: Omit<MemoryStoragePortMessage, "originator">) {
this._ports.forEach((port) => {
this.ports.forEach((port) => {
this.sendMessageTo(port, data);
});
}
@@ -296,4 +187,32 @@ export class LocalBackedSessionStorageService
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();
}
}

View File

@@ -1,5 +1,9 @@
import { Observable } from "rxjs";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider";
@@ -12,11 +16,14 @@ export class BackgroundDerivedStateProvider extends DefaultDerivedStateProvider
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
storageLocation: [string, AbstractStorageService & ObservableStorageService],
): DerivedState<TTo> {
const [cacheKey, storageService] = storageLocation;
return new BackgroundDerivedState(
parentState$,
deriveDefinition,
this.memoryStorage,
storageService,
cacheKey,
dependencies,
);
}

View File

@@ -23,10 +23,10 @@ export class BackgroundDerivedState<
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
memoryStorage: AbstractStorageService & ObservableStorageService,
portName: string,
dependencies: TDeps,
) {
super(parentState$, deriveDefinition, memoryStorage, dependencies);
const portName = deriveDefinition.buildCacheKey();
// listen for foreground derived states to connect
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {

View File

@@ -38,14 +38,21 @@ describe("foreground background derived state interactions", () => {
let memoryStorage: FakeStorageService;
const initialParent = "2020-01-01";
const ngZone = mock<NgZone>();
const portName = "testPort";
beforeEach(() => {
mockPorts();
parentState$ = new Subject<string>();
memoryStorage = new FakeStorageService();
background = new BackgroundDerivedState(parentState$, deriveDefinition, memoryStorage, {});
foreground = new ForegroundDerivedState(deriveDefinition, memoryStorage, ngZone);
background = new BackgroundDerivedState(
parentState$,
deriveDefinition,
memoryStorage,
portName,
{},
);
foreground = new ForegroundDerivedState(deriveDefinition, memoryStorage, portName, ngZone);
});
afterEach(() => {
@@ -65,7 +72,12 @@ describe("foreground background derived state interactions", () => {
});
it("should initialize a late-connected foreground", async () => {
const newForeground = new ForegroundDerivedState(deriveDefinition, memoryStorage, ngZone);
const newForeground = new ForegroundDerivedState(
deriveDefinition,
memoryStorage,
portName,
ngZone,
);
const backgroundEmissions = trackEmissions(background.state$);
parentState$.next(initialParent);
await awaitAsync();
@@ -82,8 +94,6 @@ describe("foreground background derived state interactions", () => {
const dateString = "2020-12-12";
const emissions = trackEmissions(background.state$);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
await foreground.forceValue(new Date(dateString));
await awaitAsync();
@@ -99,9 +109,7 @@ describe("foreground background derived state interactions", () => {
expect(foreground["port"]).toBeDefined();
const newDate = new Date();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
foreground.forceValue(newDate);
await foreground.forceValue(newDate);
await awaitAsync();
expect(connectMock.mock.calls.length).toBe(initialConnectCalls);
@@ -114,9 +122,7 @@ describe("foreground background derived state interactions", () => {
expect(foreground["port"]).toBeUndefined();
const newDate = new Date();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
foreground.forceValue(newDate);
await foreground.forceValue(newDate);
await awaitAsync();
expect(connectMock.mock.calls.length).toBe(initialConnectCalls + 1);

View File

@@ -5,6 +5,7 @@ import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider";
@@ -14,16 +15,18 @@ import { ForegroundDerivedState } from "./foreground-derived-state";
export class ForegroundDerivedStateProvider extends DefaultDerivedStateProvider {
constructor(
memoryStorage: AbstractStorageService & ObservableStorageService,
storageServiceProvider: StorageServiceProvider,
private ngZone: NgZone,
) {
super(memoryStorage);
super(storageServiceProvider);
}
override buildDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>(
_parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
_dependencies: TDeps,
storageLocation: [string, AbstractStorageService & ObservableStorageService],
): DerivedState<TTo> {
return new ForegroundDerivedState(deriveDefinition, this.memoryStorage, this.ngZone);
const [cacheKey, storageService] = storageLocation;
return new ForegroundDerivedState(deriveDefinition, storageService, cacheKey, this.ngZone);
}
}

View File

@@ -33,13 +33,14 @@ jest.mock("../browser/run-inside-angular.operator", () => {
describe("ForegroundDerivedState", () => {
let sut: ForegroundDerivedState<Date>;
let memoryStorage: FakeStorageService;
const portName = "testPort";
const ngZone = mock<NgZone>();
beforeEach(() => {
memoryStorage = new FakeStorageService();
memoryStorage.internalUpdateValuesRequireDeserialization(true);
mockPorts();
sut = new ForegroundDerivedState(deriveDefinition, memoryStorage, ngZone);
sut = new ForegroundDerivedState(deriveDefinition, memoryStorage, portName, ngZone);
});
afterEach(() => {

View File

@@ -35,6 +35,7 @@ export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
constructor(
private deriveDefinition: DeriveDefinition<unknown, TTo, DerivedStateDependencies>,
private memoryStorage: AbstractStorageService & ObservableStorageService,
private portName: string,
private ngZone: NgZone,
) {
this.storageKey = deriveDefinition.storageKey;
@@ -88,7 +89,7 @@ export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
return;
}
this.port = chrome.runtime.connect({ name: this.deriveDefinition.buildCacheKey() });
this.port = chrome.runtime.connect({ name: this.portName });
this.backgroundResponses$ = fromChromeEvent(this.port.onMessage).pipe(
map(([message]) => message as DerivedStateMessage),

View File

@@ -0,0 +1,35 @@
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import {
PossibleLocation,
StorageServiceProvider,
} from "@bitwarden/common/platform/services/storage-service.provider";
// eslint-disable-next-line import/no-restricted-paths
import { ClientLocations } from "@bitwarden/common/platform/state/state-definition";
export class BrowserStorageServiceProvider extends StorageServiceProvider {
constructor(
diskStorageService: AbstractStorageService & ObservableStorageService,
limitedMemoryStorageService: AbstractStorageService & ObservableStorageService,
private largeObjectMemoryStorageService: AbstractStorageService & ObservableStorageService,
) {
super(diskStorageService, limitedMemoryStorageService);
}
override get(
defaultLocation: PossibleLocation,
overrides: Partial<ClientLocations>,
): [location: PossibleLocation, service: AbstractStorageService & ObservableStorageService] {
const location = overrides["browser"] ?? defaultLocation;
switch (location) {
case "memory-large-object":
return ["memory-large-object", this.largeObjectMemoryStorageService];
default:
// Pass in computed location to super because they could have
// override default "disk" with web "memory".
return super.get(location, overrides);
}
}
}

View File

@@ -71,6 +71,7 @@ import { GlobalState } from "@bitwarden/common/platform/models/domain/global-sta
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import {
DerivedStateProvider,
@@ -108,6 +109,7 @@ import { DefaultBrowserStateService } from "../../platform/services/default-brow
import I18nService from "../../platform/services/i18n.service";
import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service";
import { ForegroundDerivedStateProvider } from "../../platform/state/foreground-derived-state.provider";
import { BrowserStorageServiceProvider } from "../../platform/storage/browser-storage-service.provider";
import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service";
import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging";
import { BrowserSendStateService } from "../../tools/popup/services/browser-send-state.service";
@@ -120,6 +122,10 @@ import { InitService } from "./init.service";
import { PopupCloseWarningService } from "./popup-close-warning.service";
import { PopupSearchService } from "./popup-search.service";
const OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE = new SafeInjectionToken<
AbstractStorageService & ObservableStorageService
>("OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE");
const needsBackgroundInit = BrowserPopupUtils.backgroundInitializationRequired();
const isPrivateMode = BrowserPopupUtils.inPrivateMode();
const mainBackground: MainBackground = needsBackgroundInit
@@ -380,6 +386,21 @@ const safeProviders: SafeProvider[] = [
},
deps: [],
}),
safeProvider({
provide: OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE,
useFactory: (
regularMemoryStorageService: AbstractMemoryStorageService & ObservableStorageService,
) => {
if (BrowserApi.isManifestVersion(2)) {
return regularMemoryStorageService;
}
return getBgService<AbstractStorageService & ObservableStorageService>(
"largeObjectMemoryStorageForStateProviders",
)();
},
deps: [OBSERVABLE_MEMORY_STORAGE],
}),
safeProvider({
provide: OBSERVABLE_DISK_STORAGE,
useExisting: AbstractStorageService,
@@ -466,7 +487,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: DerivedStateProvider,
useClass: ForegroundDerivedStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE, NgZone],
deps: [StorageServiceProvider, NgZone],
}),
safeProvider({
provide: AutofillSettingsServiceAbstraction,
@@ -542,6 +563,15 @@ const safeProviders: SafeProvider[] = [
},
deps: [],
}),
safeProvider({
provide: StorageServiceProvider,
useClass: BrowserStorageServiceProvider,
deps: [
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE,
],
}),
];
@NgModule({

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/cli",
"description": "A secure and free password manager for all of your devices.",
"version": "2024.3.1",
"version": "2024.4.0",
"keywords": [
"bitwarden",
"password",

View File

@@ -309,9 +309,7 @@ export class Main {
this.singleUserStateProvider,
);
this.derivedStateProvider = new DefaultDerivedStateProvider(
this.memoryStorageForStateProviders,
);
this.derivedStateProvider = new DefaultDerivedStateProvider(storageServiceProvider);
this.stateProvider = new DefaultStateProvider(
this.activeUserStateProvider,

View File

@@ -228,7 +228,8 @@
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"snap": {
"summary": "After installation enable required `password-manager-service` by running `sudo snap connect bitwarden:password-manager-service`.",
"summary": "Bitwarden is a secure and free password manager for all of your devices.",
"description": "**Installation**\nBitwarden requires access to the `password-manager-service`. Please enable it through permissions or by running `sudo snap connect bitwarden:password-manager-service` after installation. See https://btwrdn.com/install-snap for details.",
"autoStart": true,
"base": "core22",
"confinement": "strict",

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/desktop",
"description": "A secure and free password manager for all of your devices.",
"version": "2024.4.2",
"version": "2024.4.3",
"keywords": [
"bitwarden",
"password",

View File

@@ -157,7 +157,7 @@ export class Main {
activeUserStateProvider,
singleUserStateProvider,
globalStateProvider,
new DefaultDerivedStateProvider(this.memoryStorageForStateProviders),
new DefaultDerivedStateProvider(storageServiceProvider),
);
this.environmentService = new DefaultEnvironmentService(stateProvider, accountService);

View File

@@ -1,12 +1,12 @@
{
"name": "@bitwarden/desktop",
"version": "2024.4.2",
"version": "2024.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@bitwarden/desktop",
"version": "2024.4.2",
"version": "2024.4.3",
"license": "GPL-3.0",
"dependencies": {
"@bitwarden/desktop-native": "file:../desktop_native",

View File

@@ -2,7 +2,7 @@
"name": "@bitwarden/desktop",
"productName": "Bitwarden",
"description": "A secure and free password manager for all of your devices.",
"version": "2024.4.2",
"version": "2024.4.3",
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2024.4.1",
"version": "2024.4.2",
"scripts": {
"build:oss": "webpack",
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@@ -2,15 +2,7 @@
{{ "twoStepLoginPolicyWarning" | i18n }}
</app-callout>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>

View File

@@ -679,6 +679,14 @@ export class VaultComponent implements OnInit, OnDestroy {
} else if (result.action === CollectionDialogAction.Deleted) {
await this.collectionService.delete(result.collection?.id);
this.refresh();
// Navigate away if we deleted the collection we were viewing
if (this.selectedCollection?.node.id === c?.id) {
void this.router.navigate([], {
queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null },
queryParamsHandling: "merge",
replaceUrl: true,
});
}
}
}
@@ -710,9 +718,7 @@ export class VaultComponent implements OnInit, OnDestroy {
);
// Navigate away if we deleted the collection we were viewing
if (this.selectedCollection?.node.id === collection.id) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([], {
void this.router.navigate([], {
queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null },
queryParamsHandling: "merge",
replaceUrl: true,

View File

@@ -80,7 +80,7 @@ export class VaultHeaderComponent implements OnInit {
? this.i18nService.t("collections").toLowerCase()
: this.i18nService.t("vault").toLowerCase();
if (this.collection !== undefined) {
if (this.collection != null) {
return this.collection.node.name;
}

View File

@@ -958,11 +958,9 @@ export class VaultComponent implements OnInit, OnDestroy {
this.i18nService.t("deletedCollectionId", collection.name),
);
// Navigate away if we deleted the colletion we were viewing
// Navigate away if we deleted the collection we were viewing
if (this.selectedCollection?.node.id === collection.id) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([], {
void this.router.navigate([], {
queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null },
queryParamsHandling: "merge",
replaceUrl: true,
@@ -1095,6 +1093,18 @@ export class VaultComponent implements OnInit, OnDestroy {
result.action === CollectionDialogAction.Deleted
) {
this.refresh();
// If we deleted the selected collection, navigate up/away
if (
result.action === CollectionDialogAction.Deleted &&
this.selectedCollection?.node.id === c?.id
) {
void this.router.navigate([], {
queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null },
queryParamsHandling: "merge",
replaceUrl: true,
});
}
}
}

View File

@@ -5,15 +5,7 @@
}}</a>
</app-callout>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>

View File

@@ -1046,7 +1046,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: DerivedStateProvider,
useClass: DefaultDerivedStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE],
deps: [StorageServiceProvider],
}),
safeProvider({
provide: StateProvider,

View File

@@ -249,11 +249,11 @@ export class FakeDerivedStateProvider implements DerivedStateProvider {
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
): DerivedState<TTo> {
let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState<TTo>;
let result = this.states.get(deriveDefinition.buildCacheKey("memory")) as DerivedState<TTo>;
if (result == null) {
result = new FakeDerivedState(parentState$, deriveDefinition, dependencies);
this.states.set(deriveDefinition.buildCacheKey(), result);
this.states.set(deriveDefinition.buildCacheKey("memory"), result);
}
return result;
}

View File

@@ -4,3 +4,4 @@ export * from "./matchers";
export * from "./fake-state-provider";
export * from "./fake-state";
export * from "./fake-account-service";
export * from "./fake-storage.service";

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

View 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;
}
}

View File

@@ -171,8 +171,8 @@ export class DeriveDefinition<TFrom, TTo, TDeps extends DerivedStateDependencies
return this.options.clearOnCleanup ?? true;
}
buildCacheKey(): string {
return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}`;
buildCacheKey(location: string): string {
return `derived_${location}_${this.stateDefinition.name}_${this.uniqueDerivationName}`;
}
/**

View File

@@ -5,6 +5,7 @@ import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
import { DeriveDefinition } from "../derive-definition";
import { DerivedState } from "../derived-state";
import { DerivedStateProvider } from "../derived-state.provider";
@@ -14,14 +15,18 @@ import { DefaultDerivedState } from "./default-derived-state";
export class DefaultDerivedStateProvider implements DerivedStateProvider {
private cache: Record<string, DerivedState<unknown>> = {};
constructor(protected memoryStorage: AbstractStorageService & ObservableStorageService) {}
constructor(protected storageServiceProvider: StorageServiceProvider) {}
get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
): DerivedState<TTo> {
const cacheKey = deriveDefinition.buildCacheKey();
// TODO: we probably want to support optional normal memory storage for browser
const [location, storageService] = this.storageServiceProvider.get("memory", {
browser: "memory-large-object",
});
const cacheKey = deriveDefinition.buildCacheKey(location);
const existingDerivedState = this.cache[cacheKey];
if (existingDerivedState != null) {
// I have to cast out of the unknown generic but this should be safe if rules
@@ -29,7 +34,10 @@ export class DefaultDerivedStateProvider implements DerivedStateProvider {
return existingDerivedState as DefaultDerivedState<TFrom, TTo, TDeps>;
}
const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies);
const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies, [
location,
storageService,
]);
this.cache[cacheKey] = newDerivedState;
return newDerivedState;
}
@@ -38,11 +46,12 @@ export class DefaultDerivedStateProvider implements DerivedStateProvider {
parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
dependencies: TDeps,
storageLocation: [string, AbstractStorageService & ObservableStorageService],
): DerivedState<TTo> {
return new DefaultDerivedState<TFrom, TTo, TDeps>(
parentState$,
deriveDefinition,
this.memoryStorage,
storageLocation[1],
dependencies,
);
}

View File

@@ -72,12 +72,12 @@ describe("DefaultDerivedState", () => {
parentState$.next(dateString);
await awaitAsync();
expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(new Date(dateString)),
);
const calls = memoryStorage.mock.save.mock.calls;
expect(calls.length).toBe(1);
expect(calls[0][0]).toBe(deriveDefinition.buildCacheKey());
expect(calls[0][0]).toBe(deriveDefinition.storageKey);
expect(calls[0][1]).toEqual(derivedValue(new Date(dateString)));
});
@@ -94,7 +94,7 @@ describe("DefaultDerivedState", () => {
it("should store the forced value", async () => {
await sut.forceValue(forced);
expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(forced),
);
});
@@ -109,7 +109,7 @@ describe("DefaultDerivedState", () => {
it("should store the forced value", async () => {
await sut.forceValue(forced);
expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(forced),
);
});
@@ -153,7 +153,7 @@ describe("DefaultDerivedState", () => {
parentState$.next(newDate);
await awaitAsync();
expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(new Date(newDate)),
);
@@ -161,7 +161,7 @@ describe("DefaultDerivedState", () => {
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toBeUndefined();
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toBeUndefined();
});
it("should not clear state after cleanup if clearOnCleanup is false", async () => {
@@ -171,7 +171,7 @@ describe("DefaultDerivedState", () => {
parentState$.next(newDate);
await awaitAsync();
expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(new Date(newDate)),
);
@@ -179,7 +179,7 @@ describe("DefaultDerivedState", () => {
// Wait for cleanup
await awaitAsync(cleanupDelayMs * 2);
expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
derivedValue(new Date(newDate)),
);
});

View File

@@ -24,8 +24,10 @@ export type ClientLocations = {
web: StorageLocation | "disk-local";
/**
* Overriding storage location for browser clients.
*
* "memory-large-object" is used to store non-countable objects in memory. This exists due to limited persistent memory available to browser extensions.
*/
//browser: StorageLocation;
browser: StorageLocation | "memory-large-object";
/**
* Overriding storage location for desktop clients.
*/

View File

@@ -116,7 +116,9 @@ export const EVENT_COLLECTION_DISK = new StateDefinition("eventCollection", "dis
export const SEND_DISK = new StateDefinition("encryptedSend", "disk", {
web: "memory",
});
export const SEND_MEMORY = new StateDefinition("decryptedSend", "memory");
export const SEND_MEMORY = new StateDefinition("decryptedSend", "memory", {
browser: "memory-large-object",
});
// Vault
@@ -133,10 +135,16 @@ export const VAULT_ONBOARDING = new StateDefinition("vaultOnboarding", "disk", {
export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", {
web: "disk-local",
});
export const VAULT_BROWSER_MEMORY = new StateDefinition("vaultBrowser", "memory");
export const VAULT_SEARCH_MEMORY = new StateDefinition("vaultSearch", "memory");
export const VAULT_BROWSER_MEMORY = new StateDefinition("vaultBrowser", "memory", {
browser: "memory-large-object",
});
export const VAULT_SEARCH_MEMORY = new StateDefinition("vaultSearch", "memory", {
browser: "memory-large-object",
});
export const CIPHERS_DISK = new StateDefinition("ciphers", "disk", { web: "memory" });
export const CIPHERS_DISK_LOCAL = new StateDefinition("ciphersLocal", "disk", {
web: "disk-local",
});
export const CIPHERS_MEMORY = new StateDefinition("ciphersMemory", "memory");
export const CIPHERS_MEMORY = new StateDefinition("ciphersMemory", "memory", {
browser: "memory-large-object",
});

View File

@@ -36,7 +36,7 @@ export class EventCollectionService implements EventCollectionServiceAbstraction
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
const eventStore = this.stateProvider.getUser(userId, EVENT_COLLECTION);
if (!(await this.shouldUpdate(cipherId, organizationId))) {
if (!(await this.shouldUpdate(cipherId, organizationId, eventType))) {
return;
}
@@ -64,6 +64,7 @@ export class EventCollectionService implements EventCollectionServiceAbstraction
private async shouldUpdate(
cipherId: string = null,
organizationId: string = null,
eventType: EventType = null,
): Promise<boolean> {
const orgIds$ = this.organizationService.organizations$.pipe(
map((orgs) => orgs?.filter((o) => o.useEvents)?.map((x) => x.id) ?? []),
@@ -85,6 +86,11 @@ export class EventCollectionService implements EventCollectionServiceAbstraction
return false;
}
// Individual vault export doesn't need cipher id or organization id.
if (eventType == EventType.User_ClientExportedVault) {
return true;
}
// If the cipher is null there must be an organization id provided
if (cipher == null && organizationId == null) {
return false;

View File

@@ -47,3 +47,8 @@ $card-icons-base: "../../src/billing/images/cards/";
@import "bootstrap/scss/_print";
@import "multi-select/scss/bw.theme.scss";
// Workaround for https://bitwarden.atlassian.net/browse/CL-110
#storybook-docs pre.prismjs {
color: white;
}

8
package-lock.json generated
View File

@@ -193,11 +193,11 @@
},
"apps/browser": {
"name": "@bitwarden/browser",
"version": "2024.4.1"
"version": "2024.4.2"
},
"apps/cli": {
"name": "@bitwarden/cli",
"version": "2024.3.1",
"version": "2024.4.0",
"license": "GPL-3.0-only",
"dependencies": {
"@koa/multer": "3.0.2",
@@ -233,7 +233,7 @@
},
"apps/desktop": {
"name": "@bitwarden/desktop",
"version": "2024.4.2",
"version": "2024.4.3",
"hasInstallScript": true,
"license": "GPL-3.0"
},
@@ -247,7 +247,7 @@
},
"apps/web": {
"name": "@bitwarden/web-vault",
"version": "2024.4.1"
"version": "2024.4.2"
},
"libs/admin-console": {
"name": "@bitwarden/admin-console",