From 46a3834f4686d76b4de1805a7c0c6a59b7484461 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Wed, 10 Jan 2024 11:51:45 -0500 Subject: [PATCH] Add state for everHadUserKey (#7208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Migrate ever had user key * Add DI for state providers * Add state for everHadUserKey * Use ever had user key migrator Co-authored-by: SmithThe4th Co-authored-by: Carlos Gonçalves Co-authored-by: Jason Ng * Fix test from merge * Prefer stored observables to getters getters create a new observable every time they're called, whereas one set in the constructor is created only once. * Fix another merge issue * Fix cli background build --------- Co-authored-by: SmithThe4th Co-authored-by: Carlos Gonçalves Co-authored-by: Jason Ng --- .../browser/src/background/main.background.ts | 4 +- .../crypto-service.factory.ts | 11 +- .../services/browser-crypto.service.ts | 5 +- apps/cli/src/bw.ts | 2 + .../src/app/services/services.module.ts | 3 + .../services/electron-crypto.service.spec.ts | 26 ++- .../services/electron-crypto.service.ts | 35 ++-- libs/angular/src/auth/guards/lock.guard.ts | 5 +- .../angular/src/auth/guards/redirect.guard.ts | 5 +- .../guards/tde-decryption-required.guard.ts | 5 +- .../src/services/jslib-services.module.ts | 4 +- .../platform/abstractions/crypto.service.ts | 14 +- .../platform/abstractions/state.service.ts | 2 - .../src/platform/models/domain/account.ts | 1 - .../platform/services/crypto.service.spec.ts | 64 ++++++- .../src/platform/services/crypto.service.ts | 85 +++++---- .../src/platform/services/state.service.ts | 18 -- .../src/platform/state/state-definitions.ts | 2 + libs/common/src/state-migrations/migrate.ts | 6 +- ...er-had-user-key-to-state-providers.spec.ts | 161 ++++++++++++++++++ ...ve-ever-had-user-key-to-state-providers.ts | 46 +++++ 21 files changed, 404 insertions(+), 100 deletions(-) create mode 100644 libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 3568c6bf5e..cb6e1d4f99 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -311,7 +311,6 @@ export default class MainBackground { this.memoryStorageService as BackgroundMemoryStorageService, this.storageService as BrowserLocalStorageService, ); - this.encryptService = flagEnabled("multithreadDecryption") ? new MultithreadEncryptServiceImplementation( this.cryptoFunctionService, @@ -374,13 +373,14 @@ export default class MainBackground { window, ); this.i18nService = new BrowserI18nService(BrowserApi.getUILanguage(), this.stateService); - this.cryptoService = new BrowserCryptoService( this.cryptoFunctionService, this.encryptService, this.platformUtilsService, this.logService, this.stateService, + this.accountService, + this.stateProvider, ); this.tokenService = new TokenService(this.stateService); this.appIdService = new AppIdService(this.storageService); diff --git a/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts b/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts index 3922f3d435..1b09f4a9c3 100644 --- a/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts @@ -1,5 +1,9 @@ import { CryptoService as AbstractCryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { + AccountServiceInitOptions, + accountServiceFactory, +} from "../../../auth/background/service-factories/account-service.factory"; import { StateServiceInitOptions, stateServiceFactory, @@ -20,6 +24,7 @@ import { PlatformUtilsServiceInitOptions, platformUtilsServiceFactory, } from "./platform-utils-service.factory"; +import { StateProviderInitOptions, stateProviderFactory } from "./state-provider.factory"; type CryptoServiceFactoryOptions = FactoryOptions; @@ -28,7 +33,9 @@ export type CryptoServiceInitOptions = CryptoServiceFactoryOptions & EncryptServiceInitOptions & PlatformUtilsServiceInitOptions & LogServiceInitOptions & - StateServiceInitOptions; + StateServiceInitOptions & + AccountServiceInitOptions & + StateProviderInitOptions; export function cryptoServiceFactory( cache: { cryptoService?: AbstractCryptoService } & CachedServices, @@ -45,6 +52,8 @@ export function cryptoServiceFactory( await platformUtilsServiceFactory(cache, opts), await logServiceFactory(cache, opts), await stateServiceFactory(cache, opts), + await accountServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), ), ); } diff --git a/apps/browser/src/platform/services/browser-crypto.service.ts b/apps/browser/src/platform/services/browser-crypto.service.ts index 5439620660..9b15982c46 100644 --- a/apps/browser/src/platform/services/browser-crypto.service.ts +++ b/apps/browser/src/platform/services/browser-crypto.service.ts @@ -5,9 +5,10 @@ import { UserKey, } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CryptoService } from "@bitwarden/common/platform/services/crypto.service"; +import { UserId } from "@bitwarden/common/types/guid"; export class BrowserCryptoService extends CryptoService { - override async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: string): Promise { + override async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: UserId): Promise { if (keySuffix === KeySuffixOptions.Biometric) { return await this.stateService.getBiometricUnlock({ userId: userId }); } @@ -20,7 +21,7 @@ export class BrowserCryptoService extends CryptoService { */ protected override async getKeyFromStorage( keySuffix: KeySuffixOptions, - userId?: string, + userId?: UserId, ): Promise { if (keySuffix === KeySuffixOptions.Biometric) { await this.platformUtilService.authenticateBiometric(); diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 02be59da0a..8e887e7a33 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -268,6 +268,8 @@ export class Main { this.platformUtilsService, this.logService, this.stateService, + this.accountService, + this.stateProvider, ); this.appIdService = new AppIdService(this.storageService); diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 895ff347f2..eab6c83d55 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -37,6 +37,7 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { SystemService } from "@bitwarden/common/platform/services/system.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { DialogService } from "@bitwarden/components"; @@ -180,6 +181,8 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); PlatformUtilsServiceAbstraction, LogService, StateServiceAbstraction, + AccountServiceAbstraction, + StateProvider, ], }, ], diff --git a/apps/desktop/src/platform/services/electron-crypto.service.spec.ts b/apps/desktop/src/platform/services/electron-crypto.service.spec.ts index e3ddce7a49..c4ff9e6dd5 100644 --- a/apps/desktop/src/platform/services/electron-crypto.service.spec.ts +++ b/apps/desktop/src/platform/services/electron-crypto.service.spec.ts @@ -1,4 +1,5 @@ -import { mock, mockReset } from "jest-mock-extended"; +import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider"; +import { mock } from "jest-mock-extended"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -9,6 +10,12 @@ import { UserKey, } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { + FakeAccountService, + mockAccountServiceWith, +} from "../../../../../libs/common/spec/fake-account-service"; import { ElectronCryptoService } from "./electron-crypto.service"; import { ElectronStateService } from "./electron-state.service.abstraction"; @@ -21,15 +28,14 @@ describe("electronCryptoService", () => { const platformUtilService = mock(); const logService = mock(); const stateService = mock(); + let accountService: FakeAccountService; + let stateProvider: FakeStateProvider; - const mockUserId = "mock user id"; + const mockUserId = "mock user id" as UserId; beforeEach(() => { - mockReset(cryptoFunctionService); - mockReset(encryptService); - mockReset(platformUtilService); - mockReset(logService); - mockReset(stateService); + accountService = mockAccountServiceWith("userId" as UserId); + stateProvider = new FakeStateProvider(accountService); electronCryptoService = new ElectronCryptoService( cryptoFunctionService, @@ -37,9 +43,15 @@ describe("electronCryptoService", () => { platformUtilService, logService, stateService, + accountService, + stateProvider, ); }); + afterEach(() => { + jest.resetAllMocks(); + }); + it("instantiates", () => { expect(electronCryptoService).not.toBeFalsy(); }); diff --git a/apps/desktop/src/platform/services/electron-crypto.service.ts b/apps/desktop/src/platform/services/electron-crypto.service.ts index a42954ad1a..f7d27937c5 100644 --- a/apps/desktop/src/platform/services/electron-crypto.service.ts +++ b/apps/desktop/src/platform/services/electron-crypto.service.ts @@ -1,3 +1,4 @@ +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -11,7 +12,9 @@ import { UserKey, } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CryptoService } from "@bitwarden/common/platform/services/crypto.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; import { CsprngString } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { ElectronStateService } from "./electron-state.service.abstraction"; @@ -22,11 +25,21 @@ export class ElectronCryptoService extends CryptoService { platformUtilsService: PlatformUtilsService, logService: LogService, protected override stateService: ElectronStateService, + accountService: AccountService, + stateProvider: StateProvider, ) { - super(cryptoFunctionService, encryptService, platformUtilsService, logService, stateService); + super( + cryptoFunctionService, + encryptService, + platformUtilsService, + logService, + stateService, + accountService, + stateProvider, + ); } - override async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: string): Promise { + override async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: UserId): Promise { if (keySuffix === KeySuffixOptions.Biometric) { // TODO: Remove after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3474) const oldKey = await this.stateService.hasCryptoMasterKeyBiometric({ userId: userId }); @@ -35,7 +48,7 @@ export class ElectronCryptoService extends CryptoService { return super.hasUserKeyStored(keySuffix, userId); } - override async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: string): Promise { + override async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise { if (keySuffix === KeySuffixOptions.Biometric) { this.stateService.setUserKeyBiometric(null, { userId: userId }); this.clearDeprecatedKeys(KeySuffixOptions.Biometric, userId); @@ -44,7 +57,7 @@ export class ElectronCryptoService extends CryptoService { super.clearStoredUserKey(keySuffix, userId); } - protected override async storeAdditionalKeys(key: UserKey, userId?: string) { + protected override async storeAdditionalKeys(key: UserKey, userId?: UserId) { await super.storeAdditionalKeys(key, userId); const storeBiometricKey = await this.shouldStoreKey(KeySuffixOptions.Biometric, userId); @@ -59,7 +72,7 @@ export class ElectronCryptoService extends CryptoService { protected override async getKeyFromStorage( keySuffix: KeySuffixOptions, - userId?: string, + userId?: UserId, ): Promise { if (keySuffix === KeySuffixOptions.Biometric) { await this.migrateBiometricKeyIfNeeded(userId); @@ -69,7 +82,7 @@ export class ElectronCryptoService extends CryptoService { return await super.getKeyFromStorage(keySuffix, userId); } - protected async storeBiometricKey(key: UserKey, userId?: string): Promise { + protected async storeBiometricKey(key: UserKey, userId?: UserId): Promise { let clientEncKeyHalf: CsprngString = null; if (await this.stateService.getBiometricRequirePasswordOnStart({ userId })) { clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(userId); @@ -80,7 +93,7 @@ export class ElectronCryptoService extends CryptoService { ); } - protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId?: string): Promise { + protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise { if (keySuffix === KeySuffixOptions.Biometric) { const biometricUnlock = await this.stateService.getBiometricUnlock({ userId: userId }); return biometricUnlock && this.platformUtilService.supportsSecureStorage(); @@ -88,12 +101,12 @@ export class ElectronCryptoService extends CryptoService { return await super.shouldStoreKey(keySuffix, userId); } - protected override async clearAllStoredUserKeys(userId?: string): Promise { + protected override async clearAllStoredUserKeys(userId?: UserId): Promise { await this.stateService.setUserKeyBiometric(null, { userId: userId }); super.clearAllStoredUserKeys(userId); } - private async getBiometricEncryptionClientKeyHalf(userId?: string): Promise { + private async getBiometricEncryptionClientKeyHalf(userId?: UserId): Promise { try { let biometricKey = await this.stateService .getBiometricEncryptionClientKeyHalf({ userId }) @@ -118,7 +131,7 @@ export class ElectronCryptoService extends CryptoService { // These methods support migrating the old keys to the new ones. // TODO: Remove after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3475) - override async clearDeprecatedKeys(keySuffix: KeySuffixOptions, userId?: string) { + override async clearDeprecatedKeys(keySuffix: KeySuffixOptions, userId?: UserId) { if (keySuffix === KeySuffixOptions.Biometric) { await this.stateService.setCryptoMasterKeyBiometric(null, { userId: userId }); } @@ -126,7 +139,7 @@ export class ElectronCryptoService extends CryptoService { super.clearDeprecatedKeys(keySuffix, userId); } - private async migrateBiometricKeyIfNeeded(userId?: string) { + private async migrateBiometricKeyIfNeeded(userId?: UserId) { if (await this.stateService.hasCryptoMasterKeyBiometric({ userId })) { const oldBiometricKey = await this.stateService.getCryptoMasterKeyBiometric({ userId }); // decrypt diff --git a/libs/angular/src/auth/guards/lock.guard.ts b/libs/angular/src/auth/guards/lock.guard.ts index 9ec76126a2..f77a6c71e1 100644 --- a/libs/angular/src/auth/guards/lock.guard.ts +++ b/libs/angular/src/auth/guards/lock.guard.ts @@ -5,6 +5,7 @@ import { Router, RouterStateSnapshot, } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; @@ -19,6 +20,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl * Only allow access to this route if the vault is locked. * If TDE is enabled then the user must also have had a user key at some point. * Otherwise redirect to root. + * + * TODO: This should return Observable once we can remove all the promises */ export function lockGuard(): CanActivateFn { return async ( @@ -64,7 +67,7 @@ export function lockGuard(): CanActivateFn { // If authN user with TDE directly navigates to lock, kick them upwards so redirect guard can // properly route them to the login decryption options component. - const everHadUserKey = await cryptoService.getEverHadUserKey(); + const everHadUserKey = await firstValueFrom(cryptoService.everHadUserKey$); if (tdeEnabled && !everHadUserKey) { return router.createUrlTree(["/"]); } diff --git a/libs/angular/src/auth/guards/redirect.guard.ts b/libs/angular/src/auth/guards/redirect.guard.ts index 4853b26e71..504fcb3f36 100644 --- a/libs/angular/src/auth/guards/redirect.guard.ts +++ b/libs/angular/src/auth/guards/redirect.guard.ts @@ -1,5 +1,6 @@ import { inject } from "@angular/core"; import { CanActivateFn, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; @@ -22,6 +23,8 @@ const defaultRoutes: RedirectRoutes = { /** * Guard that consolidates all redirection logic, should be applied to root route. + * + * TODO: This should return Observable once we can get rid of all the promises */ export function redirectGuard(overrides: Partial = {}): CanActivateFn { const routes = { ...defaultRoutes, ...overrides }; @@ -44,7 +47,7 @@ export function redirectGuard(overrides: Partial = {}): CanActiv // If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the // login decryption options component. const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust(); - const everHadUserKey = await cryptoService.getEverHadUserKey(); + const everHadUserKey = await firstValueFrom(cryptoService.everHadUserKey$); if (authStatus === AuthenticationStatus.Locked && tdeEnabled && !everHadUserKey) { return router.createUrlTree([routes.notDecrypted], { queryParams: route.queryParams }); } diff --git a/libs/angular/src/auth/guards/tde-decryption-required.guard.ts b/libs/angular/src/auth/guards/tde-decryption-required.guard.ts index 84a4fef576..b2b33fcd77 100644 --- a/libs/angular/src/auth/guards/tde-decryption-required.guard.ts +++ b/libs/angular/src/auth/guards/tde-decryption-required.guard.ts @@ -5,6 +5,7 @@ import { RouterStateSnapshot, CanActivateFn, } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; @@ -14,6 +15,8 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se /** * Only allow access to this route if the vault is locked and has never been decrypted. * Otherwise redirect to root. + * + * TODO: This should return Observable once we can get rid of all the promises */ export function tdeDecryptionRequiredGuard(): CanActivateFn { return async (_: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { @@ -24,7 +27,7 @@ export function tdeDecryptionRequiredGuard(): CanActivateFn { const authStatus = await authService.getAuthStatus(); const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust(); - const everHadUserKey = await cryptoService.getEverHadUserKey(); + const everHadUserKey = await firstValueFrom(cryptoService.everHadUserKey$); if (authStatus !== AuthenticationStatus.Locked || !tdeEnabled || everHadUserKey) { return router.createUrlTree(["/"]); } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 408bce8dbe..a22b2899f9 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -396,8 +396,8 @@ import { ModalService } from "./modal.service"; PlatformUtilsServiceAbstraction, LogService, StateServiceAbstraction, - AppIdServiceAbstraction, - DevicesApiServiceAbstraction, + AccountServiceAbstraction, + StateProvider, ], }, { diff --git a/libs/common/src/platform/abstractions/crypto.service.ts b/libs/common/src/platform/abstractions/crypto.service.ts index 52849c538a..c52c86aa0e 100644 --- a/libs/common/src/platform/abstractions/crypto.service.ts +++ b/libs/common/src/platform/abstractions/crypto.service.ts @@ -1,3 +1,5 @@ +import { Observable } from "rxjs"; + import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response"; import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response"; import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response"; @@ -29,14 +31,12 @@ export abstract class CryptoService { * kicking off a refresh of any additional keys * (such as auto, biometrics, or pin) */ - /** - * Check if the current sessions has ever had a user key, i.e. has ever been unlocked/decrypted. - * This is key for differentiating between TDE locked and standard locked states. - * @param userId The desired user - * @returns True if the current session has ever had a user key - */ - getEverHadUserKey: (userId?: string) => Promise; refreshAdditionalKeys: () => Promise; + /** + * Observable value that returns whether or not the currently active user has ever had auser key, + * i.e. has ever been unlocked/decrypted. This is key for differentiating between TDE locked and standard locked states. + */ + everHadUserKey$: Observable; /** * Retrieves the user key * @param userId The desired user diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 987e13c2ea..e08f413dd1 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -393,8 +393,6 @@ export abstract class StateService { setEquivalentDomains: (value: string, options?: StorageOptions) => Promise; getEventCollection: (options?: StorageOptions) => Promise; setEventCollection: (value: EventData[], options?: StorageOptions) => Promise; - getEverHadUserKey: (options?: StorageOptions) => Promise; - setEverHadUserKey: (value: boolean, options?: StorageOptions) => Promise; getEverBeenUnlocked: (options?: StorageOptions) => Promise; setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise; getForceSetPasswordReason: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 4676a5550b..8c0a8bc8ad 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -207,7 +207,6 @@ export class AccountProfile { emailVerified?: boolean; entityId?: string; entityType?: string; - everHadUserKey?: boolean; everBeenUnlocked?: boolean; forceSetPasswordReason?: ForceSetPasswordReason; hasPremiumPersonally?: boolean; diff --git a/libs/common/src/platform/services/crypto.service.spec.ts b/libs/common/src/platform/services/crypto.service.spec.ts index 90b97b8341..f461e43242 100644 --- a/libs/common/src/platform/services/crypto.service.spec.ts +++ b/libs/common/src/platform/services/crypto.service.spec.ts @@ -1,11 +1,17 @@ -import { mock, mockReset } from "jest-mock-extended"; +import { mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; +import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; +import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state"; +import { FakeStateProvider } from "../../../spec/fake-state-provider"; import { CsprngArray } from "../../types/csprng"; +import { UserId } from "../../types/guid"; import { CryptoFunctionService } from "../abstractions/crypto-function.service"; import { EncryptService } from "../abstractions/encrypt.service"; import { LogService } from "../abstractions/log.service"; import { PlatformUtilsService } from "../abstractions/platform-utils.service"; import { StateService } from "../abstractions/state.service"; +import { Utils } from "../misc/utils"; import { EncString } from "../models/domain/enc-string"; import { MasterKey, @@ -13,7 +19,7 @@ import { SymmetricCryptoKey, UserKey, } from "../models/domain/symmetric-crypto-key"; -import { CryptoService } from "../services/crypto.service"; +import { CryptoService, USER_EVER_HAD_USER_KEY } from "../services/crypto.service"; describe("cryptoService", () => { let cryptoService: CryptoService; @@ -23,15 +29,14 @@ describe("cryptoService", () => { const platformUtilService = mock(); const logService = mock(); const stateService = mock(); + let stateProvider: FakeStateProvider; - const mockUserId = "mock user id"; + const mockUserId = Utils.newGuid() as UserId; + let accountService: FakeAccountService; beforeEach(() => { - mockReset(cryptoFunctionService); - mockReset(encryptService); - mockReset(platformUtilService); - mockReset(logService); - mockReset(stateService); + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); cryptoService = new CryptoService( cryptoFunctionService, @@ -39,9 +44,15 @@ describe("cryptoService", () => { platformUtilService, logService, stateService, + accountService, + stateProvider, ); }); + afterEach(() => { + jest.resetAllMocks(); + }); + it("instantiates", () => { expect(cryptoService).not.toBeFalsy(); }); @@ -117,12 +128,49 @@ describe("cryptoService", () => { }); }); + describe("everHadUserKey$", () => { + let everHadUserKeyState: FakeActiveUserState; + + beforeEach(() => { + everHadUserKeyState = stateProvider.activeUser.getFake(USER_EVER_HAD_USER_KEY); + }); + + it("should return true when stored value is true", async () => { + everHadUserKeyState.nextState(true); + + expect(await firstValueFrom(cryptoService.everHadUserKey$)).toBe(true); + }); + + it("should return false when stored value is false", async () => { + everHadUserKeyState.nextState(false); + + expect(await firstValueFrom(cryptoService.everHadUserKey$)).toBe(false); + }); + + it("should return false when stored value is null", async () => { + everHadUserKeyState.nextState(null); + + expect(await firstValueFrom(cryptoService.everHadUserKey$)).toBe(false); + }); + }); + describe("setUserKey", () => { let mockUserKey: UserKey; + let everHadUserKeyState: FakeSingleUserState; beforeEach(() => { const mockRandomBytes = new Uint8Array(64) as CsprngArray; mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; + everHadUserKeyState = stateProvider.singleUser.getFake(mockUserId, USER_EVER_HAD_USER_KEY); + + // Initialize storage + everHadUserKeyState.nextState(null); + }); + + it("should set everHadUserKey if key is not null to true", async () => { + await cryptoService.setUserKey(mockUserKey, mockUserId); + + expect(await firstValueFrom(everHadUserKeyState.state$)).toBe(true); }); describe("Auto Key refresh", () => { diff --git a/libs/common/src/platform/services/crypto.service.ts b/libs/common/src/platform/services/crypto.service.ts index f057053c2b..1dd3571b80 100644 --- a/libs/common/src/platform/services/crypto.service.ts +++ b/libs/common/src/platform/services/crypto.service.ts @@ -1,12 +1,15 @@ import * as bigInt from "big-integer"; +import { firstValueFrom, map } from "rxjs"; import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data"; import { BaseEncryptedOrganizationKey } from "../../admin-console/models/domain/encrypted-organization-key"; import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response"; import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response"; import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response"; +import { AccountService } from "../../auth/abstractions/account.service"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { Utils } from "../../platform/misc/utils"; +import { UserId } from "../../types/guid"; import { CryptoFunctionService } from "../abstractions/crypto-function.service"; import { CryptoService as CryptoServiceAbstraction } from "../abstractions/crypto.service"; import { EncryptService } from "../abstractions/encrypt.service"; @@ -36,34 +39,48 @@ import { SymmetricCryptoKey, UserKey, } from "../models/domain/symmetric-crypto-key"; +import { ActiveUserState, CRYPTO_DISK, KeyDefinition, StateProvider } from "../state"; + +export const USER_EVER_HAD_USER_KEY = new KeyDefinition(CRYPTO_DISK, "everHadUserKey", { + deserializer: (obj) => obj, +}); export class CryptoService implements CryptoServiceAbstraction { + private activeUserEverHadUserKey: ActiveUserState; + + readonly everHadUserKey$; + constructor( protected cryptoFunctionService: CryptoFunctionService, protected encryptService: EncryptService, protected platformUtilService: PlatformUtilsService, protected logService: LogService, protected stateService: StateService, - ) {} + protected accountService: AccountService, + protected stateProvider: StateProvider, + ) { + this.activeUserEverHadUserKey = stateProvider.getActive(USER_EVER_HAD_USER_KEY); - async setUserKey(key: UserKey, userId?: string): Promise { + this.everHadUserKey$ = this.activeUserEverHadUserKey.state$.pipe(map((x) => x ?? false)); + } + + async setUserKey(key: UserKey, userId?: UserId): Promise { + // TODO: make this non-nullable in signature + userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; if (key != null) { - await this.stateService.setEverHadUserKey(true, { userId: userId }); + // Key should never be null anyway + this.stateProvider.getUser(userId, USER_EVER_HAD_USER_KEY).update(() => true); } await this.stateService.setUserKey(key, { userId: userId }); await this.storeAdditionalKeys(key, userId); } - async getEverHadUserKey(userId?: string): Promise { - return await this.stateService.getEverHadUserKey({ userId: userId }); - } - async refreshAdditionalKeys(): Promise { const key = await this.getUserKey(); await this.setUserKey(key); } - async getUserKey(userId?: string): Promise { + async getUserKey(userId?: UserId): Promise { let userKey = await this.stateService.getUserKey({ userId: userId }); if (userKey) { return userKey; @@ -79,13 +96,13 @@ export class CryptoService implements CryptoServiceAbstraction { } } - async isLegacyUser(masterKey?: MasterKey, userId?: string): Promise { + async isLegacyUser(masterKey?: MasterKey, userId?: UserId): Promise { return await this.validateUserKey( (masterKey ?? (await this.getMasterKey(userId))) as unknown as UserKey, ); } - async getUserKeyWithLegacySupport(userId?: string): Promise { + async getUserKeyWithLegacySupport(userId?: UserId): Promise { const userKey = await this.getUserKey(userId); if (userKey) { return userKey; @@ -96,7 +113,7 @@ export class CryptoService implements CryptoServiceAbstraction { return (await this.getMasterKey(userId)) as unknown as UserKey; } - async getUserKeyFromStorage(keySuffix: KeySuffixOptions, userId?: string): Promise { + async getUserKeyFromStorage(keySuffix: KeySuffixOptions, userId?: UserId): Promise { const userKey = await this.getKeyFromStorage(keySuffix, userId); if (userKey) { if (!(await this.validateUserKey(userKey))) { @@ -113,11 +130,11 @@ export class CryptoService implements CryptoServiceAbstraction { ); } - async hasUserKeyInMemory(userId?: string): Promise { + async hasUserKeyInMemory(userId?: UserId): Promise { return (await this.stateService.getUserKey({ userId: userId })) != null; } - async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: string): Promise { + async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: UserId): Promise { return (await this.getKeyFromStorage(keySuffix, userId)) != null; } @@ -131,14 +148,14 @@ export class CryptoService implements CryptoServiceAbstraction { return this.buildProtectedSymmetricKey(masterKey, newUserKey); } - async clearUserKey(clearStoredKeys = true, userId?: string): Promise { + async clearUserKey(clearStoredKeys = true, userId?: UserId): Promise { await this.stateService.setUserKey(null, { userId: userId }); if (clearStoredKeys) { await this.clearAllStoredUserKeys(userId); } } - async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: string): Promise { + async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise { if (keySuffix === KeySuffixOptions.Auto) { this.stateService.setUserKeyAutoUnlock(null, { userId: userId }); this.clearDeprecatedKeys(KeySuffixOptions.Auto, userId); @@ -149,15 +166,15 @@ export class CryptoService implements CryptoServiceAbstraction { } } - async setMasterKeyEncryptedUserKey(userKeyMasterKey: string, userId?: string): Promise { + async setMasterKeyEncryptedUserKey(userKeyMasterKey: string, userId?: UserId): Promise { await this.stateService.setMasterKeyEncryptedUserKey(userKeyMasterKey, { userId: userId }); } - async setMasterKey(key: MasterKey, userId?: string): Promise { + async setMasterKey(key: MasterKey, userId?: UserId): Promise { await this.stateService.setMasterKey(key, { userId: userId }); } - async getMasterKey(userId?: string): Promise { + async getMasterKey(userId?: UserId): Promise { let masterKey = await this.stateService.getMasterKey({ userId: userId }); if (!masterKey) { masterKey = (await this.stateService.getCryptoMasterKey({ userId: userId })) as MasterKey; @@ -170,7 +187,7 @@ export class CryptoService implements CryptoServiceAbstraction { return masterKey; } - async getOrDeriveMasterKey(password: string, userId?: string) { + async getOrDeriveMasterKey(password: string, userId?: UserId) { let masterKey = await this.getMasterKey(userId); return (masterKey ||= await this.makeMasterKey( password, @@ -195,7 +212,7 @@ export class CryptoService implements CryptoServiceAbstraction { return (await this.makeKey(password, email, kdf, KdfConfig)) as MasterKey; } - async clearMasterKey(userId?: string): Promise { + async clearMasterKey(userId?: UserId): Promise { await this.stateService.setMasterKey(null, { userId: userId }); } @@ -210,7 +227,7 @@ export class CryptoService implements CryptoServiceAbstraction { async decryptUserKeyWithMasterKey( masterKey: MasterKey, userKey?: EncString, - userId?: string, + userId?: UserId, ): Promise { masterKey ||= await this.getMasterKey(userId); if (masterKey == null) { @@ -275,7 +292,7 @@ export class CryptoService implements CryptoServiceAbstraction { return await this.stateService.getKeyHash(); } - async clearMasterKeyHash(userId?: string): Promise { + async clearMasterKeyHash(userId?: UserId): Promise { return await this.stateService.setKeyHash(null, { userId: userId }); } @@ -389,7 +406,7 @@ export class CryptoService implements CryptoServiceAbstraction { return this.buildProtectedSymmetricKey(key, newSymKey); } - async clearOrgKeys(memoryOnly?: boolean, userId?: string): Promise { + async clearOrgKeys(memoryOnly?: boolean, userId?: UserId): Promise { await this.stateService.setDecryptedOrganizationKeys(null, { userId: userId }); if (!memoryOnly) { await this.stateService.setEncryptedOrganizationKeys(null, { userId: userId }); @@ -452,7 +469,7 @@ export class CryptoService implements CryptoServiceAbstraction { return providerKeys; } - async clearProviderKeys(memoryOnly?: boolean, userId?: string): Promise { + async clearProviderKeys(memoryOnly?: boolean, userId?: UserId): Promise { await this.stateService.setDecryptedProviderKeys(null, { userId: userId }); if (!memoryOnly) { await this.stateService.setEncryptedProviderKeys(null, { userId: userId }); @@ -537,7 +554,7 @@ export class CryptoService implements CryptoServiceAbstraction { return [publicB64, privateEnc]; } - async clearKeyPair(memoryOnly?: boolean, userId?: string): Promise { + async clearKeyPair(memoryOnly?: boolean, userId?: UserId): Promise { const keysToClear: Promise[] = [ this.stateService.setDecryptedPrivateKey(null, { userId: userId }), this.stateService.setPublicKey(null, { userId: userId }), @@ -553,7 +570,7 @@ export class CryptoService implements CryptoServiceAbstraction { return (await this.stretchKey(pinKey)) as PinKey; } - async clearPinKeys(userId?: string): Promise { + async clearPinKeys(userId?: UserId): Promise { await this.stateService.setPinKeyEncryptedUserKey(null, { userId: userId }); await this.stateService.setPinKeyEncryptedUserKeyEphemeral(null, { userId: userId }); await this.stateService.setProtectedPin(null, { userId: userId }); @@ -613,7 +630,7 @@ export class CryptoService implements CryptoServiceAbstraction { return new SymmetricCryptoKey(randomBytes) as CipherKey; } - async clearKeys(userId?: string): Promise { + async clearKeys(userId?: UserId): Promise { await this.clearUserKey(true, userId); await this.clearMasterKeyHash(userId); await this.clearOrgKeys(false, userId); @@ -776,7 +793,7 @@ export class CryptoService implements CryptoServiceAbstraction { * @param key The user key * @param userId The desired user */ - protected async storeAdditionalKeys(key: UserKey, userId?: string) { + protected async storeAdditionalKeys(key: UserKey, userId?: UserId) { const storeAuto = await this.shouldStoreKey(KeySuffixOptions.Auto, userId); if (storeAuto) { await this.stateService.setUserKeyAutoUnlock(key.keyB64, { userId: userId }); @@ -802,7 +819,7 @@ export class CryptoService implements CryptoServiceAbstraction { * ephemeral version. * @param key The user key */ - protected async storePinKey(key: UserKey, userId?: string) { + protected async storePinKey(key: UserKey, userId?: UserId) { const pin = await this.encryptService.decryptToUtf8( new EncString(await this.stateService.getProtectedPin({ userId: userId })), key, @@ -822,7 +839,7 @@ export class CryptoService implements CryptoServiceAbstraction { } } - protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId?: string) { + protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId?: UserId) { let shouldStoreKey = false; switch (keySuffix) { case KeySuffixOptions.Auto: { @@ -841,7 +858,7 @@ export class CryptoService implements CryptoServiceAbstraction { protected async getKeyFromStorage( keySuffix: KeySuffixOptions, - userId?: string, + userId?: UserId, ): Promise { if (keySuffix === KeySuffixOptions.Auto) { const userKey = await this.stateService.getUserKeyAutoUnlock({ userId: userId }); @@ -889,7 +906,7 @@ export class CryptoService implements CryptoServiceAbstraction { } } - protected async clearAllStoredUserKeys(userId?: string): Promise { + protected async clearAllStoredUserKeys(userId?: UserId): Promise { await this.stateService.setUserKeyAutoUnlock(null, { userId: userId }); await this.stateService.setPinKeyEncryptedUserKeyEphemeral(null, { userId: userId }); } @@ -984,7 +1001,7 @@ export class CryptoService implements CryptoServiceAbstraction { // These methods support migrating the old keys to the new ones. // TODO: Remove after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3475) - async clearDeprecatedKeys(keySuffix: KeySuffixOptions, userId?: string) { + async clearDeprecatedKeys(keySuffix: KeySuffixOptions, userId?: UserId) { if (keySuffix === KeySuffixOptions.Auto) { await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId }); } else if (keySuffix === KeySuffixOptions.Pin) { @@ -993,7 +1010,7 @@ export class CryptoService implements CryptoServiceAbstraction { } } - async migrateAutoKeyIfNeeded(userId?: string) { + async migrateAutoKeyIfNeeded(userId?: UserId) { const oldAutoKey = await this.stateService.getCryptoMasterKeyAuto({ userId: userId }); if (!oldAutoKey) { return; diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index f782aaf704..7be14e96b9 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -2093,24 +2093,6 @@ export class StateService< ); } - async getEverHadUserKey(options?: StorageOptions): Promise { - return ( - (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.profile?.everHadUserKey ?? false - ); - } - - async setEverHadUserKey(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.profile.everHadUserKey = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getEverBeenUnlocked(options?: StorageOptions): Promise { return ( (await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))) diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index d4943ef35f..827668d24b 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -18,3 +18,5 @@ import { StateDefinition } from "./state-definition"; */ export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); + +export const CRYPTO_DISK = new StateDefinition("crypto", "disk"); diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 0d08843b8e..aa64463551 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -5,6 +5,7 @@ import { AbstractStorageService } from "../platform/abstractions/storage.service import { MigrationBuilder } from "./migration-builder"; import { MigrationHelper } from "./migration-helper"; +import { EverHadUserKeyMigrator } from "./migrations/10-move-ever-had-user-key-to-state-providers"; import { FixPremiumMigrator } from "./migrations/3-fix-premium"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; @@ -15,7 +16,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 2; -export const CURRENT_VERSION = 9; +export const CURRENT_VERSION = 10; export type MinVersion = typeof MIN_VERSION; export async function migrate( @@ -40,7 +41,8 @@ export async function migrate( .with(RemoveLegacyEtmKeyMigrator, 5, 6) .with(MoveBiometricAutoPromptToAccount, 6, 7) .with(MoveStateVersionMigrator, 7, 8) - .with(MoveBrowserSettingsToGlobal, 8, CURRENT_VERSION) + .with(MoveBrowserSettingsToGlobal, 8, 9) + .with(EverHadUserKeyMigrator, 9, CURRENT_VERSION) .migrate(migrationHelper); } diff --git a/libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.spec.ts b/libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.spec.ts new file mode 100644 index 0000000000..40e83eb2da --- /dev/null +++ b/libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.spec.ts @@ -0,0 +1,161 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { EverHadUserKeyMigrator } from "./10-move-ever-had-user-key-to-state-providers"; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + "fd005ea6-a16a-45ef-ba4a-a194269bfd73", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + profile: { + everHadUserKey: false, + otherStuff: "overStuff2", + }, + otherStuff: "otherStuff3", + }, + "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { + profile: { + everHadUserKey: true, + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +function rollbackJSON() { + return { + "user_c493ed01-4e08-4e88-abc7-332f380ca760_crypto_everHadUserKey": false, + "user_23e61a5f-2ece-4f5e-b499-f0bc489482a9_crypto_everHadUserKey": true, + "user_fd005ea6-a16a-45ef-ba4a-a194269bfd73_crypto_everHadUserKey": false, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + "fd005ea6-a16a-45ef-ba4a-a194269bfd73", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + profile: { + everHadUserKey: false, + otherStuff: "overStuff2", + }, + otherStuff: "otherStuff3", + }, + "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { + profile: { + everHadUserKey: true, + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +describe("EverHadUserKeyMigrator", () => { + let helper: MockProxy; + let sut: EverHadUserKeyMigrator; + const keyDefinitionLike = { + key: "everHadUserKey", + stateDefinition: { + name: "crypto", + }, + }; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 9); + sut = new EverHadUserKeyMigrator(9, 10); + }); + + it("should remove everHadUserKey from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + profile: { + otherStuff: "overStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", { + profile: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + }); + + it("should set everHadUserKey provider value for each account", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith( + "c493ed01-4e08-4e88-abc7-332f380ca760", + keyDefinitionLike, + false, + ); + + expect(helper.setToUser).toHaveBeenCalledWith( + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + keyDefinitionLike, + true, + ); + + expect(helper.setToUser).toHaveBeenCalledWith( + "fd005ea6-a16a-45ef-ba4a-a194269bfd73", + keyDefinitionLike, + false, + ); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 10); + sut = new EverHadUserKeyMigrator(9, 10); + }); + + it.each([ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + "fd005ea6-a16a-45ef-ba4a-a194269bfd73", + ])("should null out new values", async (userId) => { + await sut.rollback(helper); + + expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null); + }); + + it("should add explicit value back to accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + profile: { + everHadUserKey: false, + otherStuff: "overStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", { + profile: { + everHadUserKey: true, + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + }); + + it("should not try to restore values to missing accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).not.toHaveBeenCalledWith("fd005ea6-a16a-45ef-ba4a-a194269bfd73", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.ts b/libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.ts new file mode 100644 index 0000000000..0315d5e26c --- /dev/null +++ b/libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.ts @@ -0,0 +1,46 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountType = { + profile?: { + everHadUserKey?: boolean; + }; +}; + +const USER_EVER_HAD_USER_KEY: KeyDefinitionLike = { + key: "everHadUserKey", + stateDefinition: { + name: "crypto", + }, +}; + +export class EverHadUserKeyMigrator extends Migrator<9, 10> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function migrateAccount(userId: string, account: ExpectedAccountType): Promise { + const value = account?.profile?.everHadUserKey; + await helper.setToUser(userId, USER_EVER_HAD_USER_KEY, value ?? false); + if (value != null) { + delete account.profile.everHadUserKey; + } + await helper.set(userId, account); + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise { + const value = await helper.getFromUser(userId, USER_EVER_HAD_USER_KEY); + if (account) { + account.profile = Object.assign(account.profile ?? {}, { + everHadUserKey: value, + }); + await helper.set(userId, account); + } + await helper.setToUser(userId, USER_EVER_HAD_USER_KEY, null); + } + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +}