From 5677d6265e34e61709a08df9d3be06d4c31eadda Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Fri, 1 Mar 2024 09:17:06 -0600 Subject: [PATCH] Ps/pm 5537/move biometric unlock to state providers (#8099) * Establish biometric unlock enabled in state providers * Use biometric state service for biometric state values * Migrate biometricUnlock * Fixup Dependencies * linter and import fixes * Fix injection * Fix merge * Use boolean constructor as mapper * Conform to documented test naming conventions * Commit documentation suggestion Co-authored-by: Andreas Coroiu * Fix merge commit * Fix test names --------- Co-authored-by: Andreas Coroiu --- .../browser/src/background/main.background.ts | 3 + .../background/nativeMessaging.background.ts | 8 +- .../vault-timeout-settings-service.factory.ts | 8 +- .../biometric-state-service.factory.ts | 24 ++++ .../crypto-service.factory.ts | 2 + .../services/browser-crypto.service.ts | 37 +++++- .../src/popup/settings/settings.component.ts | 2 +- apps/cli/src/bw.ts | 8 ++ .../src/app/accounts/settings.component.ts | 6 +- apps/desktop/src/auth/lock.component.ts | 2 +- .../services/electron-crypto.service.spec.ts | 6 +- .../services/electron-crypto.service.ts | 8 +- .../src/services/native-messaging.service.ts | 9 +- .../src/services/jslib-services.module.ts | 1 + .../platform/abstractions/state.service.ts | 2 - .../biometric-state.service.spec.ts | 101 +++++++++++---- .../biometrics/biometric-state.service.ts | 36 +++++- .../biometrics/biometric.state.spec.ts | 17 ++- .../platform/biometrics/biometric.state.ts | 11 ++ .../src/platform/models/domain/account.ts | 1 - .../src/platform/services/state.service.ts | 18 --- .../vault-timeout-settings.service.spec.ts | 17 ++- .../vault-timeout-settings.service.ts | 11 +- libs/common/src/state-migrations/migrate.ts | 6 +- ...iometric-unlock-to-state-providers.spec.ts | 120 ++++++++++++++++++ ...ove-biometric-unlock-to-state-providers.ts | 58 +++++++++ 26 files changed, 443 insertions(+), 79 deletions(-) create mode 100644 apps/browser/src/platform/background/service-factories/biometric-state-service.factory.ts create mode 100644 libs/common/src/state-migrations/migrations/27-move-biometric-unlock-to-state-providers.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/27-move-biometric-unlock-to-state-providers.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index b4f87fb31a2..ff1a928af07 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -453,6 +453,7 @@ export default class MainBackground { this.stateService, this.accountService, this.stateProvider, + this.biometricStateService, ); this.tokenService = new TokenService(this.stateService); this.appIdService = new AppIdService(this.storageService); @@ -619,6 +620,7 @@ export default class MainBackground { this.tokenService, this.policyService, this.stateService, + this.biometricStateService, ); this.pinCryptoService = new PinCryptoService( @@ -833,6 +835,7 @@ export default class MainBackground { this.stateService, this.logService, this.authService, + this.biometricStateService, ); this.commandsBackground = new CommandsBackground( this, diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index 3123b323721..e4fb46d960f 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -1,3 +1,5 @@ +import { firstValueFrom } from "rxjs"; + import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -8,6 +10,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; 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"; @@ -79,6 +82,7 @@ export class NativeMessagingBackground { private stateService: StateService, private logService: LogService, private authService: AuthService, + private biometricStateService: BiometricStateService, ) { // 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 @@ -321,10 +325,10 @@ export class NativeMessagingBackground { } // Check for initial setup of biometric unlock - const enabled = await this.stateService.getBiometricUnlock(); + const enabled = await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$); if (enabled === null || enabled === false) { if (message.response === "unlocked") { - await this.stateService.setBiometricUnlock(true); + await this.biometricStateService.setBiometricUnlockEnabled(true); } break; } diff --git a/apps/browser/src/background/service-factories/vault-timeout-settings-service.factory.ts b/apps/browser/src/background/service-factories/vault-timeout-settings-service.factory.ts index 9313a277619..febc605bc8a 100644 --- a/apps/browser/src/background/service-factories/vault-timeout-settings-service.factory.ts +++ b/apps/browser/src/background/service-factories/vault-timeout-settings-service.factory.ts @@ -9,6 +9,10 @@ import { tokenServiceFactory, TokenServiceInitOptions, } from "../../auth/background/service-factories/token-service.factory"; +import { + biometricStateServiceFactory, + BiometricStateServiceInitOptions, +} from "../../platform/background/service-factories/biometric-state-service.factory"; import { CryptoServiceInitOptions, cryptoServiceFactory, @@ -29,7 +33,8 @@ export type VaultTimeoutSettingsServiceInitOptions = VaultTimeoutSettingsService CryptoServiceInitOptions & TokenServiceInitOptions & PolicyServiceInitOptions & - StateServiceInitOptions; + StateServiceInitOptions & + BiometricStateServiceInitOptions; export function vaultTimeoutSettingsServiceFactory( cache: { vaultTimeoutSettingsService?: AbstractVaultTimeoutSettingsService } & CachedServices, @@ -45,6 +50,7 @@ export function vaultTimeoutSettingsServiceFactory( await tokenServiceFactory(cache, opts), await policyServiceFactory(cache, opts), await stateServiceFactory(cache, opts), + await biometricStateServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/platform/background/service-factories/biometric-state-service.factory.ts b/apps/browser/src/platform/background/service-factories/biometric-state-service.factory.ts new file mode 100644 index 00000000000..d2d4d4f9835 --- /dev/null +++ b/apps/browser/src/platform/background/service-factories/biometric-state-service.factory.ts @@ -0,0 +1,24 @@ +import { + BiometricStateService, + DefaultBiometricStateService, +} from "@bitwarden/common/platform/biometrics/biometric-state.service"; + +import { FactoryOptions, CachedServices, factory } from "./factory-options"; +import { StateProviderInitOptions, stateProviderFactory } from "./state-provider.factory"; + +type BiometricStateServiceFactoryOptions = FactoryOptions; + +export type BiometricStateServiceInitOptions = BiometricStateServiceFactoryOptions & + StateProviderInitOptions; + +export function biometricStateServiceFactory( + cache: { biometricStateService?: BiometricStateService } & CachedServices, + opts: BiometricStateServiceInitOptions, +): Promise { + return factory( + cache, + "biometricStateService", + opts, + async () => new DefaultBiometricStateService(await stateProviderFactory(cache, opts)), + ); +} 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 765530beb6f..97614660d15 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 @@ -14,6 +14,7 @@ import { } from "../../background/service-factories/log-service.factory"; import { BrowserCryptoService } from "../../services/browser-crypto.service"; +import { biometricStateServiceFactory } from "./biometric-state-service.factory"; import { cryptoFunctionServiceFactory, CryptoFunctionServiceInitOptions, @@ -60,6 +61,7 @@ export function cryptoServiceFactory( await stateServiceFactory(cache, opts), await accountServiceFactory(cache, opts), await stateProviderFactory(cache, opts), + await biometricStateServiceFactory(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 50cf5a7d750..969dbdf7618 100644 --- a/apps/browser/src/platform/services/browser-crypto.service.ts +++ b/apps/browser/src/platform/services/browser-crypto.service.ts @@ -1,15 +1,50 @@ import { firstValueFrom } from "rxjs"; +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 { 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 { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { KeySuffixOptions } from "@bitwarden/common/platform/enums"; import { CryptoService } from "@bitwarden/common/platform/services/crypto.service"; import { USER_KEY } from "@bitwarden/common/platform/services/key-state/user-key.state"; +import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; export class BrowserCryptoService extends CryptoService { + constructor( + keyGenerationService: KeyGenerationService, + cryptoFunctionService: CryptoFunctionService, + encryptService: EncryptService, + platformUtilService: PlatformUtilsService, + logService: LogService, + stateService: StateService, + accountService: AccountService, + stateProvider: StateProvider, + private biometricStateService: BiometricStateService, + ) { + super( + keyGenerationService, + cryptoFunctionService, + encryptService, + platformUtilService, + logService, + stateService, + accountService, + stateProvider, + ); + } override async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: UserId): Promise { if (keySuffix === KeySuffixOptions.Biometric) { - return await this.stateService.getBiometricUnlock({ userId: userId }); + const biometricUnlockPromise = + userId == null + ? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$) + : this.biometricStateService.getBiometricUnlockEnabled(userId); + return await biometricUnlockPromise; } return super.hasUserKeyStored(keySuffix, userId); } diff --git a/apps/browser/src/popup/settings/settings.component.ts b/apps/browser/src/popup/settings/settings.component.ts index 1914d2583a2..f622cffd3e4 100644 --- a/apps/browser/src/popup/settings/settings.component.ts +++ b/apps/browser/src/popup/settings/settings.component.ts @@ -414,7 +414,7 @@ export class SettingsComponent implements OnInit { }), ]); } else { - await this.stateService.setBiometricUnlock(null); + await this.biometricStateService.setBiometricUnlockEnabled(false); await this.stateService.setBiometricFingerprintValidated(false); } } diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 4713b947f02..46c653a87fd 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -38,6 +38,10 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s import { ClientType } from "@bitwarden/common/enums"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; +import { + BiometricStateService, + DefaultBiometricStateService, +} from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { KeySuffixOptions, LogLevelType } from "@bitwarden/common/platform/enums"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { Account } from "@bitwarden/common/platform/models/domain/account"; @@ -204,6 +208,7 @@ export class Main { derivedStateProvider: DerivedStateProvider; stateProvider: StateProvider; loginStrategyService: LoginStrategyServiceAbstraction; + biometricStateService: BiometricStateService; constructor() { let p = null; @@ -490,11 +495,14 @@ export class Main { const lockedCallback = async (userId?: string) => await this.cryptoService.clearStoredUserKey(KeySuffixOptions.Auto); + this.biometricStateService = new DefaultBiometricStateService(this.stateProvider); + this.vaultTimeoutSettingsService = new VaultTimeoutSettingsService( this.cryptoService, this.tokenService, this.policyService, this.stateService, + this.biometricStateService, ); this.pinCryptoService = new PinCryptoService( diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index ec9d226a450..32aad980f02 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -445,12 +445,12 @@ export class SettingsComponent implements OnInit { try { if (!enabled || !this.supportsBiometric) { this.form.controls.biometric.setValue(false, { emitEvent: false }); - await this.stateService.setBiometricUnlock(null); + await this.biometricStateService.setBiometricUnlockEnabled(false); await this.cryptoService.refreshAdditionalKeys(); return; } - await this.stateService.setBiometricUnlock(true); + await this.biometricStateService.setBiometricUnlockEnabled(true); if (this.isWindows) { // Recommended settings for Windows Hello this.form.controls.requirePasswordOnStart.setValue(true); @@ -465,7 +465,7 @@ export class SettingsComponent implements OnInit { const biometricSet = await this.cryptoService.hasUserKeyStored(KeySuffixOptions.Biometric); this.form.controls.biometric.setValue(biometricSet, { emitEvent: false }); if (!biometricSet) { - await this.stateService.setBiometricUnlock(null); + await this.biometricStateService.setBiometricUnlockEnabled(false); } } finally { this.messagingService.send("redrawMenu"); diff --git a/apps/desktop/src/auth/lock.component.ts b/apps/desktop/src/auth/lock.component.ts index e88ce17ca56..7403f7481d2 100644 --- a/apps/desktop/src/auth/lock.component.ts +++ b/apps/desktop/src/auth/lock.component.ts @@ -172,7 +172,7 @@ export class LockComponent extends BaseLockComponent { return; } - if (await this.stateService.getBiometricUnlock()) { + if (await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)) { const response = await this.dialogService.openSimpleDialog({ title: { key: "windowsBiometricUpdateWarningTitle" }, content: { key: "windowsBiometricUpdateWarning" }, 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 ba86df1d9bf..04adfcac708 100644 --- a/apps/desktop/src/platform/services/electron-crypto.service.spec.ts +++ b/apps/desktop/src/platform/services/electron-crypto.service.spec.ts @@ -73,8 +73,8 @@ describe("electronCryptoService", () => { encClientKeyHalf.decrypt = jest.fn().mockResolvedValue(decClientKeyHalf); }); - it("sets an Biometric key if getBiometricUnlock is true and the platform supports secure storage", async () => { - stateService.getBiometricUnlock.mockResolvedValue(true); + it("sets a Biometric key if getBiometricUnlock is true and the platform supports secure storage", async () => { + biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true); platformUtilService.supportsSecureStorage.mockReturnValue(true); biometricStateService.getRequirePasswordOnStart.mockResolvedValue(true); biometricStateService.getEncryptedClientKeyHalf.mockResolvedValue(encClientKeyHalf); @@ -90,7 +90,7 @@ describe("electronCryptoService", () => { }); it("clears the Biometric key if getBiometricUnlock is false or the platform does not support secure storage", async () => { - stateService.getBiometricUnlock.mockResolvedValue(true); + biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true); platformUtilService.supportsSecureStorage.mockReturnValue(false); await sut.setUserKey(mockUserKey, mockUserId); diff --git a/apps/desktop/src/platform/services/electron-crypto.service.ts b/apps/desktop/src/platform/services/electron-crypto.service.ts index a7e46dfb1ac..6b9327a9c4c 100644 --- a/apps/desktop/src/platform/services/electron-crypto.service.ts +++ b/apps/desktop/src/platform/services/electron-crypto.service.ts @@ -1,3 +1,5 @@ +import { firstValueFrom } from "rxjs"; + 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"; @@ -97,7 +99,11 @@ export class ElectronCryptoService extends CryptoService { protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise { if (keySuffix === KeySuffixOptions.Biometric) { - const biometricUnlock = await this.stateService.getBiometricUnlock({ userId: userId }); + const biometricUnlockPromise = + userId == null + ? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$) + : this.biometricStateService.getBiometricUnlockEnabled(userId); + const biometricUnlock = await biometricUnlockPromise; return biometricUnlock && this.platformUtilService.supportsSecureStorage(); } return await super.shouldStoreKey(keySuffix, userId); diff --git a/apps/desktop/src/services/native-messaging.service.ts b/apps/desktop/src/services/native-messaging.service.ts index e578744188c..453b1008158 100644 --- a/apps/desktop/src/services/native-messaging.service.ts +++ b/apps/desktop/src/services/native-messaging.service.ts @@ -8,10 +8,12 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { KeySuffixOptions } from "@bitwarden/common/platform/enums"; 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 { UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component"; @@ -36,6 +38,7 @@ export class NativeMessagingService { private i18nService: I18nService, private messagingService: MessagingService, private stateService: StateService, + private biometricStateService: BiometricStateService, private nativeMessageHandler: NativeMessageHandlerService, private dialogService: DialogService, private ngZone: NgZone, @@ -136,7 +139,11 @@ export class NativeMessagingService { return this.send({ command: "biometricUnlock", response: "not supported" }, appId); } - if (!(await this.stateService.getBiometricUnlock({ userId: message.userId }))) { + const biometricUnlockPromise = + message.userId == null + ? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$) + : this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId); + if (!(await biometricUnlockPromise)) { // 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.send({ command: "biometricUnlock", response: "not enabled" }, appId); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 6f6f3745b62..d20c8be1f81 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -535,6 +535,7 @@ import { ModalService } from "./modal.service"; TokenServiceAbstraction, PolicyServiceAbstraction, StateServiceAbstraction, + BiometricStateService, ], }, { diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 24da0dd3daa..1f15379109a 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -74,8 +74,6 @@ export abstract class StateService { setAutoConfirmFingerprints: (value: boolean, options?: StorageOptions) => Promise; getBiometricFingerprintValidated: (options?: StorageOptions) => Promise; setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise; - getBiometricUnlock: (options?: StorageOptions) => Promise; - setBiometricUnlock: (value: boolean, options?: StorageOptions) => Promise; getCanAccessPremium: (options?: StorageOptions) => Promise; getHasPremiumPersonally: (options?: StorageOptions) => Promise; setHasPremiumPersonally: (value: boolean, options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/biometrics/biometric-state.service.spec.ts b/libs/common/src/platform/biometrics/biometric-state.service.spec.ts index 44be94983f9..54471a25cd0 100644 --- a/libs/common/src/platform/biometrics/biometric-state.service.spec.ts +++ b/libs/common/src/platform/biometrics/biometric-state.service.spec.ts @@ -9,6 +9,7 @@ import { EncryptedString } from "../models/domain/enc-string"; import { BiometricStateService, DefaultBiometricStateService } from "./biometric-state.service"; import { + BIOMETRIC_UNLOCK_ENABLED, DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT, ENCRYPTED_CLIENT_KEY_HALF, PROMPT_AUTOMATICALLY, @@ -35,33 +36,39 @@ describe("BiometricStateService", () => { }); describe("requirePasswordOnStart$", () => { - it("should track the requirePasswordOnStart state", async () => { + it("emits when the require password on start state changes", async () => { const state = stateProvider.activeUser.getFake(REQUIRE_PASSWORD_ON_START); - state.nextState(undefined); - - expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(false); - state.nextState(true); expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(true); }); + + it("emits false when the require password on start state is undefined", async () => { + const state = stateProvider.activeUser.getFake(REQUIRE_PASSWORD_ON_START); + state.nextState(undefined); + + expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(false); + }); }); describe("encryptedClientKeyHalf$", () => { - it("should track the encryptedClientKeyHalf state", async () => { + it("emits when the encryptedClientKeyHalf state changes", async () => { const state = stateProvider.activeUser.getFake(ENCRYPTED_CLIENT_KEY_HALF); - state.nextState(undefined); - - expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toBe(null); - state.nextState(encryptedClientKeyHalf); expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toEqual(encClientKeyHalf); }); + + it("emits false when the encryptedClientKeyHalf state is undefined", async () => { + const state = stateProvider.activeUser.getFake(ENCRYPTED_CLIENT_KEY_HALF); + state.nextState(undefined); + + expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toBe(null); + }); }); describe("setEncryptedClientKeyHalf", () => { - it("should update the encryptedClientKeyHalf$", async () => { + it("updates encryptedClientKeyHalf$", async () => { await sut.setEncryptedClientKeyHalf(encClientKeyHalf); expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toEqual(encClientKeyHalf); @@ -69,13 +76,13 @@ describe("BiometricStateService", () => { }); describe("setRequirePasswordOnStart", () => { - it("should update the requirePasswordOnStart$", async () => { + it("updates the requirePasswordOnStart$", async () => { await sut.setRequirePasswordOnStart(true); expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(true); }); - it("should remove the encryptedClientKeyHalf if the value is false", async () => { + it("removes the encryptedClientKeyHalf when the set value is false", async () => { await sut.setEncryptedClientKeyHalf(encClientKeyHalf, userId); await sut.setRequirePasswordOnStart(false); @@ -87,7 +94,7 @@ describe("BiometricStateService", () => { expect(keyHalfState.nextMock).toHaveBeenCalledWith(null); }); - it("should not remove the encryptedClientKeyHalf if the value is true", async () => { + it("does not remove the encryptedClientKeyHalf when the value is true", async () => { await sut.setEncryptedClientKeyHalf(encClientKeyHalf); await sut.setRequirePasswordOnStart(true); @@ -96,7 +103,7 @@ describe("BiometricStateService", () => { }); describe("getRequirePasswordOnStart", () => { - it("should return the requirePasswordOnStart value", async () => { + it("returns the requirePasswordOnStart state value", async () => { stateProvider.singleUser.mockFor(userId, REQUIRE_PASSWORD_ON_START.key, true); expect(await sut.getRequirePasswordOnStart(userId)).toBe(true); @@ -104,17 +111,17 @@ describe("BiometricStateService", () => { }); describe("require password on start callout", () => { - it("should be false when not set", async () => { + it("is false when not set", async () => { expect(await firstValueFrom(sut.dismissedRequirePasswordOnStartCallout$)).toBe(false); }); - it("should be true when set", async () => { + it("is true when set", async () => { await sut.setDismissedRequirePasswordOnStartCallout(); expect(await firstValueFrom(sut.dismissedRequirePasswordOnStartCallout$)).toBe(true); }); - it("should update disk state", async () => { + it("updates disk state when called", async () => { await sut.setDismissedRequirePasswordOnStartCallout(); expect( @@ -123,14 +130,14 @@ describe("BiometricStateService", () => { }); }); - describe("prompt cancelled", () => { - test("observable should be updated", async () => { + describe("setPromptCancelled", () => { + test("observable is updated", async () => { await sut.setPromptCancelled(); expect(await firstValueFrom(sut.promptCancelled$)).toBe(true); }); - it("should update state with set", async () => { + it("updates state", async () => { await sut.setPromptCancelled(); const nextMock = stateProvider.activeUser.getFake(PROMPT_CANCELLED).nextMock; @@ -139,14 +146,14 @@ describe("BiometricStateService", () => { }); }); - describe("prompt automatically", () => { - test("observable should be updated", async () => { + describe("setPromptAutomatically", () => { + test("observable is updated", async () => { await sut.setPromptAutomatically(true); expect(await firstValueFrom(sut.promptAutomatically$)).toBe(true); }); - it("should update state with setPromptAutomatically", async () => { + it("updates state", async () => { await sut.setPromptAutomatically(true); const nextMock = stateProvider.activeUser.getFake(PROMPT_AUTOMATICALLY).nextMock; @@ -154,4 +161,50 @@ describe("BiometricStateService", () => { expect(nextMock).toHaveBeenCalledTimes(1); }); }); + + describe("biometricUnlockEnabled$", () => { + it("emits when biometricUnlockEnabled state is updated", async () => { + const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); + state.nextState(true); + + expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true); + }); + + it("emits false when biometricUnlockEnabled state is undefined", async () => { + const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); + state.nextState(undefined); + + expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(false); + }); + }); + + describe("setBiometricUnlockEnabled", () => { + it("updates biometricUnlockEnabled$", async () => { + await sut.setBiometricUnlockEnabled(true); + + expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true); + }); + + it("updates state", async () => { + await sut.setBiometricUnlockEnabled(true); + + expect( + stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED).nextMock, + ).toHaveBeenCalledWith([userId, true]); + }); + }); + + describe("getBiometricUnlockEnabled", () => { + it("returns biometricUnlockEnabled state for the given user", async () => { + stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true); + + expect(await sut.getBiometricUnlockEnabled(userId)).toBe(true); + }); + + it("returns false when the state is not set", async () => { + stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(undefined); + + expect(await sut.getBiometricUnlockEnabled(userId)).toBe(false); + }); + }); }); diff --git a/libs/common/src/platform/biometrics/biometric-state.service.ts b/libs/common/src/platform/biometrics/biometric-state.service.ts index 7033184dabb..b00090eb264 100644 --- a/libs/common/src/platform/biometrics/biometric-state.service.ts +++ b/libs/common/src/platform/biometrics/biometric-state.service.ts @@ -5,6 +5,7 @@ import { EncryptedString, EncString } from "../models/domain/enc-string"; import { ActiveUserState, StateProvider } from "../state"; import { + BIOMETRIC_UNLOCK_ENABLED, ENCRYPTED_CLIENT_KEY_HALF, REQUIRE_PASSWORD_ON_START, DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT, @@ -13,6 +14,10 @@ import { } from "./biometric.state"; export abstract class BiometricStateService { + /** + * `true` if the currently active user has elected to store a biometric key to unlock their vault. + */ + biometricUnlockEnabled$: Observable; // used to be biometricUnlock /** * If the user has elected to require a password on first unlock of an application instance, this key will store the * encrypted client key half used to unlock the vault. @@ -52,6 +57,16 @@ export abstract class BiometricStateService { * @param value whether or not a password is required on first unlock after opening the application */ abstract setRequirePasswordOnStart(value: boolean): Promise; + /** + * Updates the biometric unlock enabled state for the currently active user. + * @param enabled whether or not to store a biometric key to unlock the vault + */ + abstract setBiometricUnlockEnabled(enabled: boolean): Promise; + /** + * Gets the biometric unlock enabled state for the given user. + * @param userId user Id to check + */ + abstract getBiometricUnlockEnabled(userId: UserId): Promise; abstract setEncryptedClientKeyHalf(encryptedKeyHalf: EncString, userId?: UserId): Promise; abstract getEncryptedClientKeyHalf(userId: UserId): Promise; abstract getRequirePasswordOnStart(userId: UserId): Promise; @@ -78,11 +93,13 @@ export abstract class BiometricStateService { } export class DefaultBiometricStateService implements BiometricStateService { + private biometricUnlockEnabledState: ActiveUserState; private requirePasswordOnStartState: ActiveUserState; private encryptedClientKeyHalfState: ActiveUserState; private dismissedRequirePasswordOnStartCalloutState: ActiveUserState; private promptCancelledState: ActiveUserState; private promptAutomaticallyState: ActiveUserState; + biometricUnlockEnabled$: Observable; encryptedClientKeyHalf$: Observable; requirePasswordOnStart$: Observable; dismissedRequirePasswordOnStartCallout$: Observable; @@ -90,6 +107,9 @@ export class DefaultBiometricStateService implements BiometricStateService { promptAutomatically$: Observable; constructor(private stateProvider: StateProvider) { + this.biometricUnlockEnabledState = this.stateProvider.getActive(BIOMETRIC_UNLOCK_ENABLED); + this.biometricUnlockEnabled$ = this.biometricUnlockEnabledState.state$.pipe(map(Boolean)); + this.requirePasswordOnStartState = this.stateProvider.getActive(REQUIRE_PASSWORD_ON_START); this.requirePasswordOnStart$ = this.requirePasswordOnStartState.state$.pipe( map((value) => !!value), @@ -104,12 +124,22 @@ export class DefaultBiometricStateService implements BiometricStateService { DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT, ); this.dismissedRequirePasswordOnStartCallout$ = - this.dismissedRequirePasswordOnStartCalloutState.state$.pipe(map((v) => !!v)); + this.dismissedRequirePasswordOnStartCalloutState.state$.pipe(map(Boolean)); this.promptCancelledState = this.stateProvider.getActive(PROMPT_CANCELLED); - this.promptCancelled$ = this.promptCancelledState.state$.pipe(map((v) => !!v)); + this.promptCancelled$ = this.promptCancelledState.state$.pipe(map(Boolean)); this.promptAutomaticallyState = this.stateProvider.getActive(PROMPT_AUTOMATICALLY); - this.promptAutomatically$ = this.promptAutomaticallyState.state$.pipe(map((v) => !!v)); + this.promptAutomatically$ = this.promptAutomaticallyState.state$.pipe(map(Boolean)); + } + + async setBiometricUnlockEnabled(enabled: boolean): Promise { + await this.biometricUnlockEnabledState.update(() => enabled); + } + + async getBiometricUnlockEnabled(userId: UserId): Promise { + return await firstValueFrom( + this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)), + ); } async setRequirePasswordOnStart(value: boolean): Promise { diff --git a/libs/common/src/platform/biometrics/biometric.state.spec.ts b/libs/common/src/platform/biometrics/biometric.state.spec.ts index c868a5b927e..4f79f8da733 100644 --- a/libs/common/src/platform/biometrics/biometric.state.spec.ts +++ b/libs/common/src/platform/biometrics/biometric.state.spec.ts @@ -2,6 +2,7 @@ import { EncryptedString } from "../models/domain/enc-string"; import { KeyDefinition } from "../state"; import { + BIOMETRIC_UNLOCK_ENABLED, DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT, ENCRYPTED_CLIENT_KEY_HALF, PROMPT_AUTOMATICALLY, @@ -15,22 +16,20 @@ describe.each([ [PROMPT_CANCELLED, true], [PROMPT_AUTOMATICALLY, true], [REQUIRE_PASSWORD_ON_START, true], + [BIOMETRIC_UNLOCK_ENABLED, "test"], ])( "deserializes state %s", ( ...args: [KeyDefinition, EncryptedString] | [KeyDefinition, boolean] ) => { + function testDeserialization(keyDefinition: KeyDefinition, state: T) { + const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state))); + expect(deserialized).toEqual(state); + } + it("should deserialize state", () => { const [keyDefinition, state] = args; - // Need to type check to avoid TS error due to array values being unions instead of guaranteed tuple pairs - if (typeof state === "boolean") { - const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state))); - expect(deserialized).toEqual(state); - return; - } else { - const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state))); - expect(deserialized).toEqual(state); - } + testDeserialization(keyDefinition, state); }); }, ); diff --git a/libs/common/src/platform/biometrics/biometric.state.ts b/libs/common/src/platform/biometrics/biometric.state.ts index 84a4c13a5ff..8796366c884 100644 --- a/libs/common/src/platform/biometrics/biometric.state.ts +++ b/libs/common/src/platform/biometrics/biometric.state.ts @@ -1,6 +1,17 @@ import { EncryptedString } from "../models/domain/enc-string"; import { KeyDefinition, BIOMETRIC_SETTINGS_DISK } from "../state"; +/** + * Indicates whether the user elected to store a biometric key to unlock their vault. + */ +export const BIOMETRIC_UNLOCK_ENABLED = new KeyDefinition( + BIOMETRIC_SETTINGS_DISK, + "biometricUnlockEnabled", + { + deserializer: (obj) => obj, + }, +); + /** * Boolean indicating the user has elected to require a password to use their biometric key upon starting the application. * diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 5fa1039fe36..21c4e69c32a 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -200,7 +200,6 @@ export class AccountProfile { export class AccountSettings { autoConfirmFingerPrints?: boolean; - biometricUnlock?: boolean; defaultUriMatch?: UriMatchType; disableGa?: boolean; dontShowCardsCurrentTab?: boolean; diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 3db3809d246..504a92a58cc 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -378,24 +378,6 @@ export class StateService< ); } - async getBiometricUnlock(options?: StorageOptions): Promise { - return ( - (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.settings?.biometricUnlock ?? false - ); - } - - async setBiometricUnlock(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.settings.biometricUnlock = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getCanAccessPremium(options?: StorageOptions): Promise { if (!(await this.getIsAuthenticated(options))) { return false; diff --git a/libs/common/src/services/vault-timeout/vault-timeout-settings.service.spec.ts b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.spec.ts index 46d3778348e..f1881080d9c 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout-settings.service.spec.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.spec.ts @@ -1,5 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; import { Policy } from "../../admin-console/models/domain/policy"; @@ -7,6 +7,7 @@ import { TokenService } from "../../auth/abstractions/token.service"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { StateService } from "../../platform/abstractions/state.service"; +import { BiometricStateService } from "../../platform/biometrics/biometric-state.service"; import { AccountDecryptionOptions } from "../../platform/models/domain/account"; import { EncString } from "../../platform/models/domain/enc-string"; @@ -17,6 +18,7 @@ describe("VaultTimeoutSettingsService", () => { let tokenService: MockProxy; let policyService: MockProxy; let stateService: MockProxy; + const biometricStateService = mock(); let service: VaultTimeoutSettingsService; beforeEach(() => { @@ -29,7 +31,14 @@ describe("VaultTimeoutSettingsService", () => { tokenService, policyService, stateService, + biometricStateService, ); + + biometricStateService.biometricUnlockEnabled$ = of(false); + }); + + afterEach(() => { + jest.resetAllMocks(); }); describe("availableVaultTimeoutActions$", () => { @@ -66,7 +75,7 @@ describe("VaultTimeoutSettingsService", () => { }); it("contains Lock when the user has biometrics configured", async () => { - stateService.getBiometricUnlock.mockResolvedValue(true); + biometricStateService.biometricUnlockEnabled$ = of(true); const result = await firstValueFrom(service.availableVaultTimeoutActions$()); @@ -79,7 +88,7 @@ describe("VaultTimeoutSettingsService", () => { ); stateService.getPinKeyEncryptedUserKey.mockResolvedValue(null); stateService.getProtectedPin.mockResolvedValue(null); - stateService.getBiometricUnlock.mockResolvedValue(false); + biometricStateService.biometricUnlockEnabled$ = of(false); const result = await firstValueFrom(service.availableVaultTimeoutActions$()); @@ -127,7 +136,7 @@ describe("VaultTimeoutSettingsService", () => { `( "returns $expected when policy is $policy, has unlock method is $unlockMethod, and user preference is $userPreference", async ({ unlockMethod, policy, userPreference, expected }) => { - stateService.getBiometricUnlock.mockResolvedValue(unlockMethod); + biometricStateService.biometricUnlockEnabled$ = of(unlockMethod); stateService.getAccountDecryptionOptions.mockResolvedValue( new AccountDecryptionOptions({ hasMasterPassword: false }), ); diff --git a/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts index 6bb7c73f6a0..e84d561fe6a 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts @@ -1,4 +1,4 @@ -import { defer } from "rxjs"; +import { defer, firstValueFrom } from "rxjs"; import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; @@ -7,6 +7,8 @@ import { TokenService } from "../../auth/abstractions/token.service"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { StateService } from "../../platform/abstractions/state.service"; +import { BiometricStateService } from "../../platform/biometrics/biometric-state.service"; +import { UserId } from "../../types/guid"; /** * - DISABLED: No Pin set @@ -21,6 +23,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA private tokenService: TokenService, private policyService: PolicyService, private stateService: StateService, + private biometricStateService: BiometricStateService, ) {} async setVaultTimeoutOptions(timeout: number, action: VaultTimeoutAction): Promise { @@ -74,7 +77,11 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA } async isBiometricLockSet(userId?: string): Promise { - return await this.stateService.getBiometricUnlock({ userId }); + const biometricUnlockPromise = + userId == null + ? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$) + : this.biometricStateService.getBiometricUnlockEnabled(userId as UserId); + return await biometricUnlockPromise; } async getVaultTimeout(userId?: string): Promise { diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index bec8e0241f6..f09a1fe7ae0 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -21,6 +21,7 @@ import { MoveBiometricPromptsToStateProviders } from "./migrations/23-move-biome import { SmOnboardingTasksMigrator } from "./migrations/24-move-sm-onboarding-key-to-state-providers"; import { ClearClipboardDelayMigrator } from "./migrations/25-move-clear-clipboard-to-autofill-settings-state-provider"; import { BadgeSettingsMigrator } from "./migrations/26-move-badge-settings-to-state-providers"; +import { MoveBiometricUnlockToStateProviders } from "./migrations/27-move-biometric-unlock-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"; @@ -31,7 +32,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 2; -export const CURRENT_VERSION = 26; +export const CURRENT_VERSION = 27; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -60,7 +61,8 @@ export function createMigrationBuilder() { .with(MoveBiometricPromptsToStateProviders, 22, 23) .with(SmOnboardingTasksMigrator, 23, 24) .with(ClearClipboardDelayMigrator, 24, 25) - .with(BadgeSettingsMigrator, 25, CURRENT_VERSION); + .with(BadgeSettingsMigrator, 25, 26) + .with(MoveBiometricUnlockToStateProviders, 26, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/27-move-biometric-unlock-to-state-providers.spec.ts b/libs/common/src/state-migrations/migrations/27-move-biometric-unlock-to-state-providers.spec.ts new file mode 100644 index 00000000000..89693dff570 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/27-move-biometric-unlock-to-state-providers.spec.ts @@ -0,0 +1,120 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + BIOMETRIC_UNLOCK_ENABLED, + MoveBiometricUnlockToStateProviders, +} from "./27-move-biometric-unlock-to-state-providers"; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2", "user-3"], + "user-1": { + settings: { + biometricUnlock: true, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + otherStuff: "otherStuff4", + }, + }; +} + +function rollbackJSON() { + return { + "user_user-1_biometricSettings_biometricUnlockEnabled": true, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2", "user-3"], + "user-1": { + settings: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + otherStuff: "otherStuff4", + }, + }; +} + +describe("MoveBiometricPromptsToStateProviders migrator", () => { + let helper: MockProxy; + let sut: MoveBiometricUnlockToStateProviders; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 26); + sut = new MoveBiometricUnlockToStateProviders(26, 27); + }); + + it("removes biometricUnlock from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledTimes(2); + expect(helper.set).toHaveBeenCalledWith("user-1", { + settings: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("user-2", { + otherStuff: "otherStuff4", + }); + }); + + it("sets biometricUnlock value for account that have it", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("user-1", BIOMETRIC_UNLOCK_ENABLED, true); + }); + + it("should not call extra setToUser", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledTimes(1); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 27); + sut = new MoveBiometricUnlockToStateProviders(26, 27); + }); + + it("nulls out new values", async () => { + await sut.rollback(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("user-1", BIOMETRIC_UNLOCK_ENABLED, null); + }); + + it("adds explicit value back to accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("user-1", { + settings: { + biometricUnlock: true, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it.each(["user-2", "user-3"])( + "does not restore values when accounts are not present", + async (userId) => { + await sut.rollback(helper); + + expect(helper.set).not.toHaveBeenCalledWith(userId, any()); + }, + ); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/27-move-biometric-unlock-to-state-providers.ts b/libs/common/src/state-migrations/migrations/27-move-biometric-unlock-to-state-providers.ts new file mode 100644 index 00000000000..147f7c7f06e --- /dev/null +++ b/libs/common/src/state-migrations/migrations/27-move-biometric-unlock-to-state-providers.ts @@ -0,0 +1,58 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountType = { + settings?: { + biometricUnlock?: boolean; + }; +}; + +export const BIOMETRIC_UNLOCK_ENABLED: KeyDefinitionLike = { + key: "biometricUnlockEnabled", + stateDefinition: { name: "biometricSettings" }, +}; + +export class MoveBiometricUnlockToStateProviders extends Migrator<26, 27> { + async migrate(helper: MigrationHelper): Promise { + const legacyAccounts = await helper.getAccounts(); + + await Promise.all( + legacyAccounts.map(async ({ userId, account }) => { + if (account == null) { + return; + } + // Move account data + if (account?.settings?.biometricUnlock != null) { + await helper.setToUser( + userId, + BIOMETRIC_UNLOCK_ENABLED, + account.settings.biometricUnlock, + ); + } + + // Delete old account data + delete account?.settings?.biometricUnlock; + await helper.set(userId, account); + }), + ); + } + + async rollback(helper: MigrationHelper): Promise { + async function rollbackUser(userId: string, account: ExpectedAccountType) { + const biometricUnlock = await helper.getFromUser(userId, BIOMETRIC_UNLOCK_ENABLED); + + if (biometricUnlock != null) { + account ??= {}; + account.settings ??= {}; + + account.settings.biometricUnlock = biometricUnlock; + await helper.setToUser(userId, BIOMETRIC_UNLOCK_ENABLED, null); + await helper.set(userId, account); + } + } + + const accounts = await helper.getAccounts(); + + await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account))); + } +}