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:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"devFlags": {
|
||||
"storeSessionDecrypted": false,
|
||||
"managedEnvironment": {
|
||||
"base": "https://localhost:8080"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -9,7 +9,7 @@ jest.mock("../flags", () => ({
|
||||
}));
|
||||
|
||||
class TestClass {
|
||||
@devFlag("storeSessionDecrypted") test() {
|
||||
@devFlag("managedEnvironment") test() {
|
||||
return "test";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -157,7 +157,7 @@ export class Main {
|
||||
activeUserStateProvider,
|
||||
singleUserStateProvider,
|
||||
globalStateProvider,
|
||||
new DefaultDerivedStateProvider(this.memoryStorageForStateProviders),
|
||||
new DefaultDerivedStateProvider(storageServiceProvider),
|
||||
);
|
||||
|
||||
this.environmentService = new DefaultEnvironmentService(stateProvider, accountService);
|
||||
|
||||
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1046,7 +1046,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: DerivedStateProvider,
|
||||
useClass: DefaultDerivedStateProvider,
|
||||
deps: [OBSERVABLE_MEMORY_STORAGE],
|
||||
deps: [StorageServiceProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: StateProvider,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
8
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user