// FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { firstValueFrom, map } from "rxjs"; import { Jsonify, JsonValue } from "type-fest"; import { AccountService } from "../../auth/abstractions/account.service"; import { TokenService } from "../../auth/abstractions/token.service"; import { BiometricKey } from "../../auth/types/biometric-key"; import { UserId } from "../../types/guid"; import { EnvironmentService } from "../abstractions/environment.service"; import { LogService } from "../abstractions/log.service"; import { InitOptions, StateService as StateServiceAbstraction, } from "../abstractions/state.service"; import { AbstractStorageService } from "../abstractions/storage.service"; import { HtmlStorageLocation, StorageLocation } from "../enums"; import { StateFactory } from "../factories/state-factory"; import { Account } from "../models/domain/account"; import { GlobalState } from "../models/domain/global-state"; import { State } from "../models/domain/state"; import { StorageOptions } from "../models/domain/storage-options"; import { MigrationRunner } from "./migration-runner"; const keys = { state: "state", stateVersion: "stateVersion", global: "global", tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication }; const partialKeys = { userAutoKey: "_user_auto", userBiometricKey: "_user_biometric", autoKey: "_masterkey_auto", masterKey: "_masterkey", }; const DDG_SHARED_KEY = "DuckDuckGoSharedKey"; export class StateService< TGlobalState extends GlobalState = GlobalState, TAccount extends Account = Account, > implements StateServiceAbstraction { private hasBeenInited = false; protected isRecoveredSession = false; // default account serializer, must be overridden by child class protected accountDeserializer = Account.fromJSON as (json: Jsonify) => TAccount; constructor( protected storageService: AbstractStorageService, protected secureStorageService: AbstractStorageService, protected memoryStorageService: AbstractStorageService, protected logService: LogService, protected stateFactory: StateFactory, protected accountService: AccountService, protected environmentService: EnvironmentService, protected tokenService: TokenService, private migrationRunner: MigrationRunner, ) {} async init(initOptions: InitOptions = {}): Promise { // Deconstruct and apply defaults const { runMigrations = true } = initOptions; if (this.hasBeenInited) { return; } if (runMigrations) { await this.migrationRunner.run(); } else { // It may have been requested to not run the migrations but we should defensively not // continue this method until migrations have a chance to be completed elsewhere. await this.migrationRunner.waitForCompletion(); } await this.state().then(async (state) => { if (state == null) { await this.setState(new State(this.createGlobals())); } else { this.isRecoveredSession = true; } }); await this.initAccountState(); this.hasBeenInited = true; } async initAccountState() { if (this.isRecoveredSession) { return; } // Get all likely authenticated accounts const authenticatedAccounts = await firstValueFrom( this.accountService.accounts$.pipe(map((accounts) => Object.keys(accounts))), ); await this.updateState(async (state) => { for (const i in authenticatedAccounts) { state = await this.syncAccountFromDisk(authenticatedAccounts[i]); } return state; }); } async syncAccountFromDisk(userId: string): Promise> { if (userId == null) { return; } const diskAccount = await this.getAccountFromDisk({ userId: userId }); const state = await this.updateState(async (state) => { if (state.accounts == null) { state.accounts = {}; } state.accounts[userId] = this.createAccount(); if (diskAccount == null) { // Return early because we can't set the diskAccount.profile // if diskAccount itself is null return state; } state.accounts[userId].profile = diskAccount.profile; return state; }); return state; } async addAccount(account: TAccount) { await this.updateState(async (state) => { state.accounts[account.profile.userId] = account; return state; }); await this.scaffoldNewAccountStorage(account); } async clean(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); await this.deAuthenticateAccount(options.userId); await this.removeAccountFromDisk(options?.userId); await this.removeAccountFromMemory(options?.userId); } /** * user key when using the "never" option of vault timeout */ async getUserKeyAutoUnlock(options?: StorageOptions): Promise { options = this.reconcileOptions( this.reconcileOptions(options, { keySuffix: "auto" }), await this.defaultSecureStorageOptions(), ); if (options?.userId == null) { return null; } return await this.secureStorageService.get( `${options.userId}${partialKeys.userAutoKey}`, options, ); } /** * user key when using the "never" option of vault timeout */ async setUserKeyAutoUnlock(value: string | null, options?: StorageOptions): Promise { options = this.reconcileOptions( this.reconcileOptions(options, { keySuffix: "auto" }), await this.defaultSecureStorageOptions(), ); if (options?.userId == null) { return; } await this.saveSecureStorageKey(partialKeys.userAutoKey, value, options); } /** * User's encrypted symmetric key when using biometrics */ async getUserKeyBiometric(options?: StorageOptions): Promise { options = this.reconcileOptions( this.reconcileOptions(options, { keySuffix: "biometric" }), await this.defaultSecureStorageOptions(), ); if (options?.userId == null) { return null; } return await this.secureStorageService.get( `${options.userId}${partialKeys.userBiometricKey}`, options, ); } async hasUserKeyBiometric(options?: StorageOptions): Promise { options = this.reconcileOptions( this.reconcileOptions(options, { keySuffix: "biometric" }), await this.defaultSecureStorageOptions(), ); if (options?.userId == null) { return false; } return await this.secureStorageService.has( `${options.userId}${partialKeys.userBiometricKey}`, options, ); } async setUserKeyBiometric(value: BiometricKey, options?: StorageOptions): Promise { options = this.reconcileOptions( this.reconcileOptions(options, { keySuffix: "biometric" }), await this.defaultSecureStorageOptions(), ); if (options?.userId == null) { return; } await this.saveSecureStorageKey(partialKeys.userBiometricKey, value, options); } async getDuckDuckGoSharedKey(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); if (options?.userId == null) { return null; } return await this.secureStorageService.get(DDG_SHARED_KEY, options); } async setDuckDuckGoSharedKey(value: string, options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); if (options?.userId == null) { return; } value == null ? await this.secureStorageService.remove(DDG_SHARED_KEY, options) : await this.secureStorageService.save(DDG_SHARED_KEY, value, options); } async setEnableDuckDuckGoBrowserIntegration( value: boolean, options?: StorageOptions, ): Promise { const globals = await this.getGlobals( this.reconcileOptions(options, await this.defaultOnDiskOptions()), ); globals.enableDuckDuckGoBrowserIntegration = value; await this.saveGlobals( globals, this.reconcileOptions(options, await this.defaultOnDiskOptions()), ); } /** * @deprecated Use UserKey instead */ async getEncryptedCryptoSymmetricKey(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) )?.keys.cryptoSymmetricKey.encrypted; } async getIsAuthenticated(options?: StorageOptions): Promise { return ( (await this.tokenService.getAccessToken(options?.userId as UserId)) != null && (await this.getUserId(options)) != null ); } async getUserId(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) )?.profile?.userId; } protected async getGlobals(options: StorageOptions): Promise { let globals: TGlobalState; if (this.useMemory(options.storageLocation)) { globals = await this.getGlobalsFromMemory(); } if (this.useDisk && globals == null) { globals = await this.getGlobalsFromDisk(options); } if (globals == null) { globals = this.createGlobals(); } return globals; } protected async saveGlobals(globals: TGlobalState, options: StorageOptions) { return this.useMemory(options.storageLocation) ? this.saveGlobalsToMemory(globals) : await this.saveGlobalsToDisk(globals, options); } protected async getGlobalsFromMemory(): Promise { return (await this.state()).globals; } protected async getGlobalsFromDisk(options: StorageOptions): Promise { return await this.storageService.get(keys.global, options); } protected async saveGlobalsToMemory(globals: TGlobalState): Promise { await this.updateState(async (state) => { state.globals = globals; return state; }); } protected async saveGlobalsToDisk(globals: TGlobalState, options: StorageOptions): Promise { if (options.useSecureStorage) { await this.secureStorageService.save(keys.global, globals, options); } else { await this.storageService.save(keys.global, globals, options); } } protected async getAccount(options: StorageOptions): Promise { try { let account: TAccount; if (this.useMemory(options.storageLocation)) { account = await this.getAccountFromMemory(options); } if (this.useDisk(options.storageLocation) && account == null) { account = await this.getAccountFromDisk(options); } return account; } catch (e) { this.logService.error(e); } } protected async getAccountFromMemory(options: StorageOptions): Promise { const userId = options.userId ?? (await firstValueFrom( this.accountService.activeAccount$.pipe(map((account) => account?.id)), )); return await this.state().then(async (state) => { if (state.accounts == null) { return null; } return state.accounts[userId]; }); } protected async getAccountFromDisk(options: StorageOptions): Promise { const userId = options.userId ?? (await firstValueFrom( this.accountService.activeAccount$.pipe(map((account) => account?.id)), )); if (userId == null) { return null; } const account = options?.useSecureStorage ? ((await this.secureStorageService.get(options.userId, options)) ?? (await this.storageService.get( options.userId, this.reconcileOptions(options, { htmlStorageLocation: HtmlStorageLocation.Local }), ))) : await this.storageService.get(options.userId, options); return account; } protected useMemory(storageLocation: StorageLocation) { return storageLocation === StorageLocation.Memory || storageLocation === StorageLocation.Both; } protected useDisk(storageLocation: StorageLocation) { return storageLocation === StorageLocation.Disk || storageLocation === StorageLocation.Both; } protected async saveAccount( account: TAccount, options: StorageOptions = { storageLocation: StorageLocation.Both, useSecureStorage: false, }, ) { return this.useMemory(options.storageLocation) ? await this.saveAccountToMemory(account) : await this.saveAccountToDisk(account, options); } protected async saveAccountToDisk(account: TAccount, options: StorageOptions): Promise { const storageLocation = options.useSecureStorage ? this.secureStorageService : this.storageService; await storageLocation.save(`${options.userId}`, account, options); } protected async saveAccountToMemory(account: TAccount): Promise { if ((await this.getAccountFromMemory({ userId: account.profile.userId })) !== null) { await this.updateState((state) => { return new Promise((resolve) => { state.accounts[account.profile.userId] = account; resolve(state); }); }); } } protected async scaffoldNewAccountStorage(account: TAccount): Promise { // We don't want to manipulate the referenced in memory account const deepClone = JSON.parse(JSON.stringify(account)); await this.scaffoldNewAccountLocalStorage(deepClone); await this.scaffoldNewAccountSessionStorage(deepClone); await this.scaffoldNewAccountMemoryStorage(deepClone); } // TODO: There is a tech debt item for splitting up these methods - only Web uses multiple storage locations in its storageService. // For now these methods exist with some redundancy to facilitate this special web requirement. protected async scaffoldNewAccountLocalStorage(account: TAccount): Promise { await this.saveAccount( account, this.reconcileOptions( { userId: account.profile.userId }, await this.defaultOnDiskLocalOptions(), ), ); } protected async scaffoldNewAccountMemoryStorage(account: TAccount): Promise { await this.storageService.save( account.profile.userId, account, await this.defaultOnDiskMemoryOptions(), ); await this.saveAccount( account, this.reconcileOptions( { userId: account.profile.userId }, await this.defaultOnDiskMemoryOptions(), ), ); } protected async scaffoldNewAccountSessionStorage(account: TAccount): Promise { await this.storageService.save( account.profile.userId, account, await this.defaultOnDiskMemoryOptions(), ); await this.saveAccount( account, this.reconcileOptions({ userId: account.profile.userId }, await this.defaultOnDiskOptions()), ); } protected reconcileOptions( requestedOptions: StorageOptions, defaultOptions: StorageOptions, ): StorageOptions { if (requestedOptions == null) { return defaultOptions; } requestedOptions.userId = requestedOptions?.userId ?? defaultOptions.userId; requestedOptions.storageLocation = requestedOptions?.storageLocation ?? defaultOptions.storageLocation; requestedOptions.useSecureStorage = requestedOptions?.useSecureStorage ?? defaultOptions.useSecureStorage; requestedOptions.htmlStorageLocation = requestedOptions?.htmlStorageLocation ?? defaultOptions.htmlStorageLocation; requestedOptions.keySuffix = requestedOptions?.keySuffix ?? defaultOptions.keySuffix; return requestedOptions; } protected async defaultInMemoryOptions(): Promise { const userId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((account) => account?.id)), ); return { storageLocation: StorageLocation.Memory, userId, }; } protected async defaultOnDiskOptions(): Promise { const userId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((account) => account?.id)), ); return { storageLocation: StorageLocation.Disk, htmlStorageLocation: HtmlStorageLocation.Session, userId, useSecureStorage: false, }; } protected async defaultOnDiskLocalOptions(): Promise { const userId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((account) => account?.id)), ); return { storageLocation: StorageLocation.Disk, htmlStorageLocation: HtmlStorageLocation.Local, userId, useSecureStorage: false, }; } protected async defaultOnDiskMemoryOptions(): Promise { const userId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((account) => account?.id)), ); return { storageLocation: StorageLocation.Disk, htmlStorageLocation: HtmlStorageLocation.Memory, userId, useSecureStorage: false, }; } protected async defaultSecureStorageOptions(): Promise { const userId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((account) => account?.id)), ); return { storageLocation: StorageLocation.Disk, useSecureStorage: true, userId, }; } protected async getActiveUserIdFromStorage(): Promise { return await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))); } protected async removeAccountFromLocalStorage(userId: string = null): Promise { userId ??= await firstValueFrom( this.accountService.activeAccount$.pipe(map((account) => account?.id)), ); const storedAccount = await this.getAccount( this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()), ); await this.saveAccount( this.resetAccount(storedAccount), this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()), ); } protected async removeAccountFromSessionStorage(userId: string = null): Promise { userId ??= await firstValueFrom( this.accountService.activeAccount$.pipe(map((account) => account?.id)), ); const storedAccount = await this.getAccount( this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()), ); await this.saveAccount( this.resetAccount(storedAccount), this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()), ); } protected async removeAccountFromSecureStorage(userId: string = null): Promise { userId ??= await firstValueFrom( this.accountService.activeAccount$.pipe(map((account) => account?.id)), ); await this.setUserKeyAutoUnlock(null, { userId: userId }); await this.setUserKeyBiometric(null, { userId: userId }); } protected async removeAccountFromMemory(userId: string = null): Promise { userId ??= await firstValueFrom( this.accountService.activeAccount$.pipe(map((account) => account?.id)), ); await this.updateState(async (state) => { delete state.accounts[userId]; return state; }); } // settings persist even on reset, and are not affected by this method protected resetAccount(account: TAccount) { // All settings have been moved to StateProviders return this.createAccount(); } protected createAccount(init: Partial = null): TAccount { return this.stateFactory.createAccount(init); } protected createGlobals(init: Partial = null): TGlobalState { return this.stateFactory.createGlobal(init); } protected async deAuthenticateAccount(userId: string): Promise { // We must have a manual call to clear tokens as we can't leverage state provider to clean // up our data as we have secure storage in the mix. await this.tokenService.clearTokens(userId as UserId); } protected async removeAccountFromDisk(userId: string) { await this.removeAccountFromSessionStorage(userId); await this.removeAccountFromLocalStorage(userId); await this.removeAccountFromSecureStorage(userId); } protected async saveSecureStorageKey( key: string, value: T | null, options?: StorageOptions, ) { return value == null ? await this.secureStorageService.remove(`${options.userId}${key}`, options) : await this.secureStorageService.save(`${options.userId}${key}`, value, options); } protected async state(): Promise> { let state = await this.memoryStorageService.get>(keys.state); if (this.memoryStorageService.valuesRequireDeserialization) { state = State.fromJSON(state, this.accountDeserializer); } return state; } private async setState( state: State, ): Promise> { await this.memoryStorageService.save(keys.state, state); return state; } protected async updateState( stateUpdater: (state: State) => Promise>, ): Promise> { return await this.state().then(async (state) => { const updatedState = await stateUpdater(state); if (updatedState == null) { throw new Error("Attempted to update state to null value"); } return await this.setState(updatedState); }); } }