diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 6faa0389561..c738b6eed96 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -143,6 +143,8 @@ import { DefaultStateProvider } from "@bitwarden/common/platform/state/implement import { InlineDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/inline-derived-state"; import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service"; /* eslint-enable import/no-restricted-paths */ +import { PrimarySecondaryStorageService } from "@bitwarden/common/platform/storage/primary-secondary-storage.service"; +import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service"; import { SyncService } from "@bitwarden/common/platform/sync"; // eslint-disable-next-line no-restricted-imports -- Needed for service creation import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal"; @@ -239,6 +241,7 @@ import { ForegroundTaskSchedulerService } from "../platform/services/task-schedu 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 { OffscreenStorageService } from "../platform/storage/offscreen-storage.service"; import { ForegroundSyncService } from "../platform/sync/foreground-sync.service"; import { SyncServiceListener } from "../platform/sync/sync-service.listener"; import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging"; @@ -485,10 +488,15 @@ export default class MainBackground { ? mv3MemoryStorageCreator() // mv3 stores to local-backed session storage : this.memoryStorageForStateProviders; // mv2 stores to the same location + const localStorageStorageService = BrowserApi.isManifestVersion(3) + ? new OffscreenStorageService(this.offscreenDocumentService) + : new WindowStorageService(self.localStorage); + const storageServiceProvider = new BrowserStorageServiceProvider( this.storageService, this.memoryStorageForStateProviders, this.largeObjectMemoryStorageForStateProviders, + new PrimarySecondaryStorageService(this.storageService, localStorageStorageService), ); this.globalStateProvider = new DefaultGlobalStateProvider( diff --git a/apps/browser/src/platform/offscreen-document/offscreen-document.ts b/apps/browser/src/platform/offscreen-document/offscreen-document.ts index 4994a6e9ba8..938e3191e0d 100644 --- a/apps/browser/src/platform/offscreen-document/offscreen-document.ts +++ b/apps/browser/src/platform/offscreen-document/offscreen-document.ts @@ -14,6 +14,9 @@ class OffscreenDocument implements OffscreenDocumentInterface { private readonly extensionMessageHandlers: OffscreenDocumentExtensionMessageHandlers = { offscreenCopyToClipboard: ({ message }) => this.handleOffscreenCopyToClipboard(message), offscreenReadFromClipboard: () => this.handleOffscreenReadFromClipboard(), + localStorageGet: ({ message }) => this.handleLocalStorageGet(message.key), + localStorageSave: ({ message }) => this.handleLocalStorageSave(message.key, message.value), + localStorageRemove: ({ message }) => this.handleLocalStorageRemove(message.key), }; /** @@ -39,6 +42,18 @@ class OffscreenDocument implements OffscreenDocumentInterface { return await BrowserClipboardService.read(self); } + private handleLocalStorageGet(key: string) { + return self.localStorage.getItem(key); + } + + private handleLocalStorageSave(key: string, value: string) { + self.localStorage.setItem(key, value); + } + + private handleLocalStorageRemove(key: string) { + self.localStorage.removeItem(key); + } + /** * Sets up the listener for extension messages. */ diff --git a/apps/browser/src/platform/storage/browser-storage-service.provider.ts b/apps/browser/src/platform/storage/browser-storage-service.provider.ts index e0214baef44..5854669138a 100644 --- a/apps/browser/src/platform/storage/browser-storage-service.provider.ts +++ b/apps/browser/src/platform/storage/browser-storage-service.provider.ts @@ -14,6 +14,7 @@ export class BrowserStorageServiceProvider extends StorageServiceProvider { diskStorageService: AbstractStorageService & ObservableStorageService, limitedMemoryStorageService: AbstractStorageService & ObservableStorageService, private largeObjectMemoryStorageService: AbstractStorageService & ObservableStorageService, + private readonly diskBackupLocalStorage: AbstractStorageService & ObservableStorageService, ) { super(diskStorageService, limitedMemoryStorageService); } @@ -26,6 +27,8 @@ export class BrowserStorageServiceProvider extends StorageServiceProvider { switch (location) { case "memory-large-object": return ["memory-large-object", this.largeObjectMemoryStorageService]; + case "disk-backup-local-storage": + return ["disk-backup-local-storage", this.diskBackupLocalStorage]; default: // Pass in computed location to super because they could have // override default "disk" with web "memory". diff --git a/apps/browser/src/platform/storage/offscreen-storage.service.ts b/apps/browser/src/platform/storage/offscreen-storage.service.ts new file mode 100644 index 00000000000..34d3bd7a9ac --- /dev/null +++ b/apps/browser/src/platform/storage/offscreen-storage.service.ts @@ -0,0 +1,55 @@ +import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; + +import { BrowserApi } from "../browser/browser-api"; +import { OffscreenDocumentService } from "../offscreen-document/abstractions/offscreen-document"; + +export class OffscreenStorageService implements AbstractStorageService { + constructor(private readonly offscreenDocumentService: OffscreenDocumentService) {} + + get valuesRequireDeserialization(): boolean { + return true; + } + + async get(key: string, options?: StorageOptions): Promise { + return await this.offscreenDocumentService.withDocument( + [chrome.offscreen.Reason.LOCAL_STORAGE], + "backup storage of user data", + async () => { + const response = await BrowserApi.sendMessageWithResponse("localStorageGet", { + key, + }); + if (response != null) { + return JSON.parse(response); + } + + return response; + }, + ); + } + async has(key: string, options?: StorageOptions): Promise { + return (await this.get(key, options)) != null; + } + + async save(key: string, obj: T, options?: StorageOptions): Promise { + await this.offscreenDocumentService.withDocument( + [chrome.offscreen.Reason.LOCAL_STORAGE], + "backup storage of user data", + async () => + await BrowserApi.sendMessageWithResponse("localStorageSave", { + key, + value: JSON.stringify(obj), + }), + ); + } + async remove(key: string, options?: StorageOptions): Promise { + await this.offscreenDocumentService.withDocument( + [chrome.offscreen.Reason.LOCAL_STORAGE], + "backup storage of user data", + async () => + await BrowserApi.sendMessageWithResponse("localStorageRemove", { + key, + }), + ); + } +} diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 2fba41d17ad..f6f3bf732b0 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -73,6 +73,8 @@ import { } from "@bitwarden/common/platform/state"; // eslint-disable-next-line import/no-restricted-paths -- Used for dependency injection import { InlineDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/inline-derived-state"; +import { PrimarySecondaryStorageService } from "@bitwarden/common/platform/storage/primary-secondary-storage.service"; +import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -122,6 +124,10 @@ const OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE = new SafeInjectionToken< AbstractStorageService & ObservableStorageService >("OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE"); +const DISK_BACKUP_LOCAL_STORAGE = new SafeInjectionToken< + AbstractStorageService & ObservableStorageService +>("DISK_BACKUP_LOCAL_STORAGE"); + const needsBackgroundInit = BrowserPopupUtils.backgroundInitializationRequired(); const mainBackground: MainBackground = needsBackgroundInit ? createLocalBgService() @@ -496,6 +502,12 @@ const safeProviders: SafeProvider[] = [ }, deps: [], }), + safeProvider({ + provide: DISK_BACKUP_LOCAL_STORAGE, + useFactory: (diskStorage: AbstractStorageService & ObservableStorageService) => + new PrimarySecondaryStorageService(diskStorage, new WindowStorageService(self.localStorage)), + deps: [OBSERVABLE_DISK_STORAGE], + }), safeProvider({ provide: StorageServiceProvider, useClass: BrowserStorageServiceProvider, @@ -503,6 +515,7 @@ const safeProviders: SafeProvider[] = [ OBSERVABLE_DISK_STORAGE, OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE, + DISK_BACKUP_LOCAL_STORAGE, ], }), safeProvider({ diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 4d8acbde0d1..3b7ba0a727a 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -49,6 +49,7 @@ import { StorageServiceProvider } from "@bitwarden/common/platform/services/stor import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state"; import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service"; /* eslint-enable import/no-restricted-paths -- Implementation for memory storage */ +import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service"; import { DefaultThemeStateService, ThemeStateService, @@ -63,7 +64,6 @@ import { I18nService } from "../core/i18n.service"; import { WebEnvironmentService } from "../platform/web-environment.service"; import { WebMigrationRunner } from "../platform/web-migration-runner"; import { WebStorageServiceProvider } from "../platform/web-storage-service.provider"; -import { WindowStorageService } from "../platform/window-storage.service"; import { CollectionAdminService } from "../vault/core/collection-admin.service"; import { EventService } from "./event.service"; diff --git a/apps/web/src/app/platform/web-migration-runner.spec.ts b/apps/web/src/app/platform/web-migration-runner.spec.ts index 4b2949230e4..c27be4a145e 100644 --- a/apps/web/src/app/platform/web-migration-runner.spec.ts +++ b/apps/web/src/app/platform/web-migration-runner.spec.ts @@ -3,11 +3,11 @@ import { MockProxy, mock } from "jest-mock-extended"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; +import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service"; import { MigrationBuilder } from "@bitwarden/common/state-migrations/migration-builder"; import { MigrationHelper } from "@bitwarden/common/state-migrations/migration-helper"; import { WebMigrationRunner } from "./web-migration-runner"; -import { WindowStorageService } from "./window-storage.service"; describe("WebMigrationRunner", () => { let logService: MockProxy; diff --git a/apps/web/src/app/platform/web-migration-runner.ts b/apps/web/src/app/platform/web-migration-runner.ts index 392eeeae045..7ac10cd2e08 100644 --- a/apps/web/src/app/platform/web-migration-runner.ts +++ b/apps/web/src/app/platform/web-migration-runner.ts @@ -4,10 +4,9 @@ import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; +import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service"; import { MigrationHelper } from "@bitwarden/common/state-migrations/migration-helper"; -import { WindowStorageService } from "./window-storage.service"; - export class WebMigrationRunner extends MigrationRunner { constructor( diskStorage: AbstractStorageService, diff --git a/libs/common/src/platform/state/state-definition.ts b/libs/common/src/platform/state/state-definition.ts index f1e7dc80abd..3caa03c95a5 100644 --- a/libs/common/src/platform/state/state-definition.ts +++ b/libs/common/src/platform/state/state-definition.ts @@ -25,9 +25,12 @@ export type ClientLocations = { /** * 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. + * `"memory-large-object"` is used to store non-countable objects in memory. This exists due to limited persistent memory available to browser extensions. + * + * `"disk-backup-local-storage"` is used to store object in both disk and in `localStorage`. Data is stored in both locations but is only retrieved + * from `localStorage` when a null-ish value is retrieved from disk first. */ - browser: StorageLocation | "memory-large-object"; + browser: StorageLocation | "memory-large-object" | "disk-backup-local-storage"; /** * Overriding storage location for desktop clients. */ diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index f7609e877d8..cbf4cd28f2f 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -46,6 +46,7 @@ export const AUTH_REQUEST_DISK_LOCAL = new StateDefinition("authRequestLocal", " export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" }); export const DEVICE_TRUST_DISK_LOCAL = new StateDefinition("deviceTrust", "disk", { web: "disk-local", + browser: "disk-backup-local-storage", }); export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk"); export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk"); diff --git a/libs/common/src/platform/storage/primary-secondary-storage.service.ts b/libs/common/src/platform/storage/primary-secondary-storage.service.ts new file mode 100644 index 00000000000..df62010fdc1 --- /dev/null +++ b/libs/common/src/platform/storage/primary-secondary-storage.service.ts @@ -0,0 +1,59 @@ +import { AbstractStorageService, ObservableStorageService } from "../abstractions/storage.service"; +import { StorageOptions } from "../models/domain/storage-options"; + +export class PrimarySecondaryStorageService + implements AbstractStorageService, ObservableStorageService +{ + // Only follow the primary storage service as updates should all be done to both + updates$ = this.primaryStorageService.updates$; + + constructor( + private readonly primaryStorageService: AbstractStorageService & ObservableStorageService, + // Secondary service doesn't need to be observable as the only `updates$` are listened to from the primary store + private readonly secondaryStorageService: AbstractStorageService, + ) { + if ( + primaryStorageService.valuesRequireDeserialization !== + secondaryStorageService.valuesRequireDeserialization + ) { + throw new Error( + "Differing values for valuesRequireDeserialization between storage services is not supported.", + ); + } + } + get valuesRequireDeserialization(): boolean { + return this.primaryStorageService.valuesRequireDeserialization; + } + + async get(key: string, options?: StorageOptions): Promise { + const primaryValue = await this.primaryStorageService.get(key, options); + + // If it's null-ish try the secondary location for its value + if (primaryValue == null) { + return await this.secondaryStorageService.get(key, options); + } + + return primaryValue; + } + + async has(key: string, options?: StorageOptions): Promise { + return ( + (await this.primaryStorageService.has(key, options)) || + (await this.secondaryStorageService.has(key, options)) + ); + } + + async save(key: string, obj: T, options?: StorageOptions): Promise { + await Promise.allSettled([ + this.primaryStorageService.save(key, obj, options), + this.secondaryStorageService.save(key, obj, options), + ]); + } + + async remove(key: string, options?: StorageOptions): Promise { + await Promise.allSettled([ + this.primaryStorageService.remove(key, options), + this.secondaryStorageService.remove(key, options), + ]); + } +} diff --git a/apps/web/src/app/platform/window-storage.service.ts b/libs/common/src/platform/storage/window-storage.service.ts similarity index 90% rename from apps/web/src/app/platform/window-storage.service.ts rename to libs/common/src/platform/storage/window-storage.service.ts index d5ba1283a95..4eba94b2f2b 100644 --- a/apps/web/src/app/platform/window-storage.service.ts +++ b/libs/common/src/platform/storage/window-storage.service.ts @@ -4,8 +4,8 @@ import { AbstractStorageService, ObservableStorageService, StorageUpdate, -} from "@bitwarden/common/platform/abstractions/storage.service"; -import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; +} from "../abstractions/storage.service"; +import { StorageOptions } from "../models/domain/storage-options"; export class WindowStorageService implements AbstractStorageService, ObservableStorageService { private readonly updatesSubject = new Subject();