mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 15:53:27 +00:00
[PM-5537] Migrate Biometric Prompts (#7771)
* Fix nextMock arguments
* Add state for biometric prompts
* Use biometric state for prompts
* Migrate biometric prompt data
* wire up biometric state to logouts
* Add migrator to migrate list
* Remove usages of prompt automatically
Explicitly list non-nulled state as intentional
* `npm run prettier` 🤖
* Fix web lock component
This commit is contained in:
@@ -23,6 +23,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 { HashPurpose, KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
@@ -73,6 +74,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
|
||||
protected userVerificationService: UserVerificationService,
|
||||
protected pinCryptoService: PinCryptoServiceAbstraction,
|
||||
protected biometricStateService: BiometricStateService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -117,7 +119,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stateService.setBiometricPromptCancelled(true);
|
||||
await this.biometricStateService.setPromptCancelled();
|
||||
const userKey = await this.cryptoService.getUserKeyFromStorage(KeySuffixOptions.Biometric);
|
||||
|
||||
if (userKey) {
|
||||
@@ -274,7 +276,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
|
||||
private async doContinue(evaluatePasswordAfterUnlock: boolean) {
|
||||
await this.stateService.setEverBeenUnlocked(true);
|
||||
await this.stateService.setBiometricPromptCancelled(false);
|
||||
await this.biometricStateService.resetPromptCancelled();
|
||||
this.messagingService.send("unlocked");
|
||||
|
||||
if (evaluatePasswordAfterUnlock) {
|
||||
|
||||
@@ -66,6 +66,7 @@ export class FakeGlobalState<T> implements GlobalState<T> {
|
||||
});
|
||||
|
||||
updateMock = this.update as jest.MockedFunction<typeof this.update>;
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [T]>();
|
||||
|
||||
get state$() {
|
||||
@@ -128,6 +129,7 @@ export class FakeSingleUserState<T> implements SingleUserState<T> {
|
||||
|
||||
updateMock = this.update as jest.MockedFunction<typeof this.update>;
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [T]>();
|
||||
private _keyDefinition: KeyDefinition<T> | null = null;
|
||||
get keyDefinition() {
|
||||
@@ -190,6 +192,7 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> {
|
||||
|
||||
updateMock = this.update as jest.MockedFunction<typeof this.update>;
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [[UserId, T]]>();
|
||||
|
||||
private _keyDefinition: KeyDefinition<T> | null = null;
|
||||
|
||||
@@ -169,18 +169,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
* @deprecated For migration purposes only, use setUserKeyBiometric instead
|
||||
*/
|
||||
setCryptoMasterKeyBiometric: (value: BiometricKey, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* Gets a flag for if the biometrics process has been cancelled.
|
||||
* Process reload occurs when biometrics is cancelled, so we store to disk to prevent
|
||||
* it from reprompting and creating a loop.
|
||||
*/
|
||||
getBiometricPromptCancelled: (options?: StorageOptions) => Promise<boolean>;
|
||||
/**
|
||||
* Sets a flag for if the biometrics process has been cancelled.
|
||||
* Process reload occurs when biometrics is cancelled, so we store to disk to prevent
|
||||
* it from reprompting and creating a loop.
|
||||
*/
|
||||
setBiometricPromptCancelled: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedCiphers: (options?: StorageOptions) => Promise<CipherView[]>;
|
||||
setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise<void>;
|
||||
getDecryptedPasswordGenerationHistory: (
|
||||
@@ -218,8 +206,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setDefaultUriMatch: (value: UriMatchType, options?: StorageOptions) => Promise<void>;
|
||||
getDisableAddLoginNotification: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDisableAddLoginNotification: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDisableAutoBiometricsPrompt: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDisableAutoBiometricsPrompt: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDisableBadgeCounter: (options?: StorageOptions) => Promise<boolean>;
|
||||
setDisableBadgeCounter: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getDisableChangedPasswordNotification: (options?: StorageOptions) => Promise<boolean>;
|
||||
|
||||
@@ -8,7 +8,13 @@ import { UserId } from "../../types/guid";
|
||||
import { EncryptedString } from "../models/domain/enc-string";
|
||||
|
||||
import { BiometricStateService, DefaultBiometricStateService } from "./biometric-state.service";
|
||||
import { ENCRYPTED_CLIENT_KEY_HALF, REQUIRE_PASSWORD_ON_START } from "./biometric.state";
|
||||
import {
|
||||
DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
ENCRYPTED_CLIENT_KEY_HALF,
|
||||
PROMPT_AUTOMATICALLY,
|
||||
PROMPT_CANCELLED,
|
||||
REQUIRE_PASSWORD_ON_START,
|
||||
} from "./biometric.state";
|
||||
|
||||
describe("BiometricStateService", () => {
|
||||
let sut: BiometricStateService;
|
||||
@@ -96,4 +102,56 @@ describe("BiometricStateService", () => {
|
||||
expect(await sut.getRequirePasswordOnStart(userId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("require password on start callout", () => {
|
||||
it("should be false when not set", async () => {
|
||||
expect(await firstValueFrom(sut.dismissedRequirePasswordOnStartCallout$)).toBe(false);
|
||||
});
|
||||
|
||||
it("should be true when set", async () => {
|
||||
await sut.setDismissedRequirePasswordOnStartCallout();
|
||||
|
||||
expect(await firstValueFrom(sut.dismissedRequirePasswordOnStartCallout$)).toBe(true);
|
||||
});
|
||||
|
||||
it("should update disk state", async () => {
|
||||
await sut.setDismissedRequirePasswordOnStartCallout();
|
||||
|
||||
expect(
|
||||
stateProvider.activeUser.getFake(DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT).nextMock,
|
||||
).toHaveBeenCalledWith([userId, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompt cancelled", () => {
|
||||
test("observable should be updated", async () => {
|
||||
await sut.setPromptCancelled();
|
||||
|
||||
expect(await firstValueFrom(sut.promptCancelled$)).toBe(true);
|
||||
});
|
||||
|
||||
it("should update state with set", async () => {
|
||||
await sut.setPromptCancelled();
|
||||
|
||||
const nextMock = stateProvider.activeUser.getFake(PROMPT_CANCELLED).nextMock;
|
||||
expect(nextMock).toHaveBeenCalledWith([userId, true]);
|
||||
expect(nextMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompt automatically", () => {
|
||||
test("observable should be updated", async () => {
|
||||
await sut.setPromptAutomatically(true);
|
||||
|
||||
expect(await firstValueFrom(sut.promptAutomatically$)).toBe(true);
|
||||
});
|
||||
|
||||
it("should update state with setPromptAutomatically", async () => {
|
||||
await sut.setPromptAutomatically(true);
|
||||
|
||||
const nextMock = stateProvider.activeUser.getFake(PROMPT_AUTOMATICALLY).nextMock;
|
||||
expect(nextMock).toHaveBeenCalledWith([userId, true]);
|
||||
expect(nextMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,13 @@ import { UserId } from "../../types/guid";
|
||||
import { EncryptedString, EncString } from "../models/domain/enc-string";
|
||||
import { ActiveUserState, StateProvider } from "../state";
|
||||
|
||||
import { ENCRYPTED_CLIENT_KEY_HALF, REQUIRE_PASSWORD_ON_START } from "./biometric.state";
|
||||
import {
|
||||
ENCRYPTED_CLIENT_KEY_HALF,
|
||||
REQUIRE_PASSWORD_ON_START,
|
||||
DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
PROMPT_AUTOMATICALLY,
|
||||
PROMPT_CANCELLED,
|
||||
} from "./biometric.state";
|
||||
|
||||
export abstract class BiometricStateService {
|
||||
/**
|
||||
@@ -20,6 +26,24 @@ export abstract class BiometricStateService {
|
||||
* tracks the currently active user
|
||||
*/
|
||||
requirePasswordOnStart$: Observable<boolean>;
|
||||
/**
|
||||
* Indicates the user has been warned about the security implications of using biometrics and, depending on the OS,
|
||||
*
|
||||
* tracks the currently active user.
|
||||
*/
|
||||
dismissedRequirePasswordOnStartCallout$: Observable<boolean>;
|
||||
/**
|
||||
* Whether the user has cancelled the biometric prompt.
|
||||
*
|
||||
* tracks the currently active user
|
||||
*/
|
||||
promptCancelled$: Observable<boolean>;
|
||||
/**
|
||||
* Whether the user has elected to automatically prompt for biometrics.
|
||||
*
|
||||
* tracks the currently active user
|
||||
*/
|
||||
promptAutomatically$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Updates the require password on start state for the currently active user.
|
||||
@@ -32,13 +56,38 @@ export abstract class BiometricStateService {
|
||||
abstract getEncryptedClientKeyHalf(userId: UserId): Promise<EncString>;
|
||||
abstract getRequirePasswordOnStart(userId: UserId): Promise<boolean>;
|
||||
abstract removeEncryptedClientKeyHalf(userId: UserId): Promise<void>;
|
||||
/**
|
||||
* Updates the active user's state to reflect that they've been warned about requiring password on start.
|
||||
*/
|
||||
abstract setDismissedRequirePasswordOnStartCallout(): Promise<void>;
|
||||
/**
|
||||
* Updates the active user's state to reflect that they've cancelled the biometric prompt this lock.
|
||||
*/
|
||||
abstract setPromptCancelled(): Promise<void>;
|
||||
/**
|
||||
* Resets the active user's state to reflect that they haven't cancelled the biometric prompt this lock.
|
||||
*/
|
||||
abstract resetPromptCancelled(): Promise<void>;
|
||||
/**
|
||||
* Updates the currently active user's setting for auto prompting for biometrics on application start and lock
|
||||
* @param prompt Whether or not to prompt for biometrics on application start.
|
||||
*/
|
||||
abstract setPromptAutomatically(prompt: boolean): Promise<void>;
|
||||
|
||||
abstract logout(userId: UserId): Promise<void>;
|
||||
}
|
||||
|
||||
export class DefaultBiometricStateService implements BiometricStateService {
|
||||
private requirePasswordOnStartState: ActiveUserState<boolean>;
|
||||
private encryptedClientKeyHalfState: ActiveUserState<EncryptedString | undefined>;
|
||||
private dismissedRequirePasswordOnStartCalloutState: ActiveUserState<boolean>;
|
||||
private promptCancelledState: ActiveUserState<boolean>;
|
||||
private promptAutomaticallyState: ActiveUserState<boolean>;
|
||||
encryptedClientKeyHalf$: Observable<EncString | undefined>;
|
||||
requirePasswordOnStart$: Observable<boolean>;
|
||||
dismissedRequirePasswordOnStartCallout$: Observable<boolean>;
|
||||
promptCancelled$: Observable<boolean>;
|
||||
promptAutomatically$: Observable<boolean>;
|
||||
|
||||
constructor(private stateProvider: StateProvider) {
|
||||
this.requirePasswordOnStartState = this.stateProvider.getActive(REQUIRE_PASSWORD_ON_START);
|
||||
@@ -50,6 +99,17 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
this.encryptedClientKeyHalf$ = this.encryptedClientKeyHalfState.state$.pipe(
|
||||
map(encryptedClientKeyHalfToEncString),
|
||||
);
|
||||
|
||||
this.dismissedRequirePasswordOnStartCalloutState = this.stateProvider.getActive(
|
||||
DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
);
|
||||
this.dismissedRequirePasswordOnStartCallout$ =
|
||||
this.dismissedRequirePasswordOnStartCalloutState.state$.pipe(map((v) => !!v));
|
||||
|
||||
this.promptCancelledState = this.stateProvider.getActive(PROMPT_CANCELLED);
|
||||
this.promptCancelled$ = this.promptCancelledState.state$.pipe(map((v) => !!v));
|
||||
this.promptAutomaticallyState = this.stateProvider.getActive(PROMPT_AUTOMATICALLY);
|
||||
this.promptAutomatically$ = this.promptAutomaticallyState.state$.pipe(map((v) => !!v));
|
||||
}
|
||||
|
||||
async setRequirePasswordOnStart(value: boolean): Promise<void> {
|
||||
@@ -97,6 +157,25 @@ export class DefaultBiometricStateService implements BiometricStateService {
|
||||
|
||||
async logout(userId: UserId): Promise<void> {
|
||||
await this.stateProvider.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF).update(() => null);
|
||||
await this.stateProvider.getUser(userId, PROMPT_CANCELLED).update(() => null);
|
||||
// Persist auto prompt setting through logout
|
||||
// Persist dismissed require password on start callout through logout
|
||||
}
|
||||
|
||||
async setDismissedRequirePasswordOnStartCallout(): Promise<void> {
|
||||
await this.dismissedRequirePasswordOnStartCalloutState.update(() => true);
|
||||
}
|
||||
|
||||
async setPromptCancelled(): Promise<void> {
|
||||
await this.promptCancelledState.update(() => true);
|
||||
}
|
||||
|
||||
async resetPromptCancelled(): Promise<void> {
|
||||
await this.promptCancelledState.update(() => null);
|
||||
}
|
||||
|
||||
async setPromptAutomatically(prompt: boolean): Promise<void> {
|
||||
await this.promptAutomaticallyState.update(() => prompt);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
import { ENCRYPTED_CLIENT_KEY_HALF, REQUIRE_PASSWORD_ON_START } from "./biometric.state";
|
||||
import { EncryptedString } from "../models/domain/enc-string";
|
||||
import { KeyDefinition } from "../state";
|
||||
|
||||
describe("require password on start", () => {
|
||||
const sut = REQUIRE_PASSWORD_ON_START;
|
||||
import {
|
||||
DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
ENCRYPTED_CLIENT_KEY_HALF,
|
||||
PROMPT_AUTOMATICALLY,
|
||||
PROMPT_CANCELLED,
|
||||
REQUIRE_PASSWORD_ON_START,
|
||||
} from "./biometric.state";
|
||||
|
||||
it("should deserialize require password on start state", () => {
|
||||
const requirePasswordOnStart = "requirePasswordOnStart";
|
||||
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(requirePasswordOnStart)));
|
||||
|
||||
expect(result).toEqual(requirePasswordOnStart);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encrypted client key half", () => {
|
||||
const sut = ENCRYPTED_CLIENT_KEY_HALF;
|
||||
|
||||
it("should deserialize encrypted client key half state", () => {
|
||||
const encryptedClientKeyHalf = "encryptedClientKeyHalf";
|
||||
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(encryptedClientKeyHalf)));
|
||||
|
||||
expect(result).toEqual(encryptedClientKeyHalf);
|
||||
});
|
||||
});
|
||||
describe.each([
|
||||
[ENCRYPTED_CLIENT_KEY_HALF, "encryptedClientKeyHalf"],
|
||||
[DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT, true],
|
||||
[PROMPT_CANCELLED, true],
|
||||
[PROMPT_AUTOMATICALLY, true],
|
||||
[REQUIRE_PASSWORD_ON_START, true],
|
||||
])(
|
||||
"deserializes state %s",
|
||||
(
|
||||
...args: [KeyDefinition<EncryptedString>, EncryptedString] | [KeyDefinition<boolean>, boolean]
|
||||
) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -28,3 +28,38 @@ export const ENCRYPTED_CLIENT_KEY_HALF = new KeyDefinition<EncryptedString>(
|
||||
deserializer: (obj) => obj,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Indicates the user has been warned about the security implications of using biometrics and, depending on the OS,
|
||||
* recommended to require a password on first unlock of an application instance.
|
||||
*/
|
||||
export const DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT = new KeyDefinition<boolean>(
|
||||
BIOMETRIC_SETTINGS_DISK,
|
||||
"dismissedBiometricRequirePasswordOnStartCallout",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Stores whether the user has elected to cancel the biometric prompt. This is stored on disk due to process-reload
|
||||
* wiping memory state. We don't want to prompt the user again if they've elected to cancel.
|
||||
*/
|
||||
export const PROMPT_CANCELLED = new KeyDefinition<boolean>(
|
||||
BIOMETRIC_SETTINGS_DISK,
|
||||
"promptCancelled",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Stores whether the user has elected to automatically prompt for biometric unlock on application start.
|
||||
*/
|
||||
export const PROMPT_AUTOMATICALLY = new KeyDefinition<boolean>(
|
||||
BIOMETRIC_SETTINGS_DISK,
|
||||
"promptAutomatically",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -203,7 +203,6 @@ export class AccountSettings {
|
||||
biometricUnlock?: boolean;
|
||||
clearClipboard?: number;
|
||||
defaultUriMatch?: UriMatchType;
|
||||
disableAutoBiometricsPrompt?: boolean;
|
||||
disableBadgeCounter?: boolean;
|
||||
disableGa?: boolean;
|
||||
dontShowCardsCurrentTab?: boolean;
|
||||
@@ -227,7 +226,6 @@ export class AccountSettings {
|
||||
avatarColor?: string;
|
||||
smOnboardingTasks?: Record<string, Record<string, boolean>>;
|
||||
trustDeviceChoiceForDecryption?: boolean;
|
||||
biometricPromptCancelled?: boolean;
|
||||
|
||||
/** @deprecated July 2023, left for migration purposes*/
|
||||
pinProtected?: EncryptionPair<string, EncString> = new EncryptionPair<string, EncString>();
|
||||
|
||||
@@ -775,24 +775,6 @@ export class StateService<
|
||||
await this.saveSecureStorageKey(partialKeys.biometricKey, value, options);
|
||||
}
|
||||
|
||||
async getBiometricPromptCancelled(options?: StorageOptions): Promise<boolean> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
return account?.settings?.biometricPromptCancelled;
|
||||
}
|
||||
|
||||
async setBiometricPromptCancelled(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
account.settings.biometricPromptCancelled = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototypeForArrayMembers(CipherView, CipherView.fromJSON)
|
||||
async getDecryptedCiphers(options?: StorageOptions): Promise<CipherView[]> {
|
||||
return (
|
||||
@@ -928,24 +910,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getDisableAutoBiometricsPrompt(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
?.settings?.disableAutoBiometricsPrompt ?? false
|
||||
);
|
||||
}
|
||||
|
||||
async setDisableAutoBiometricsPrompt(value: boolean, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
account.settings.disableAutoBiometricsPrompt = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getDisableBadgeCounter(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
|
||||
@@ -17,6 +17,7 @@ import { RequirePasswordOnStartMigrator } from "./migrations/19-migrate-require-
|
||||
import { PrivateKeyMigrator } from "./migrations/20-move-private-key-to-state-providers";
|
||||
import { CollectionMigrator } from "./migrations/21-move-collections-state-to-state-provider";
|
||||
import { CollapsedGroupingsMigrator } from "./migrations/22-move-collapsed-groupings-to-state-provider";
|
||||
import { MoveBiometricPromptsToStateProviders } from "./migrations/23-move-biometric-prompts-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";
|
||||
@@ -27,7 +28,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
|
||||
import { MinVersionMigrator } from "./migrations/min-version";
|
||||
|
||||
export const MIN_VERSION = 2;
|
||||
export const CURRENT_VERSION = 22;
|
||||
export const CURRENT_VERSION = 23;
|
||||
export type MinVersion = typeof MIN_VERSION;
|
||||
|
||||
export function createMigrationBuilder() {
|
||||
@@ -52,7 +53,8 @@ export function createMigrationBuilder() {
|
||||
.with(RequirePasswordOnStartMigrator, 18, 19)
|
||||
.with(PrivateKeyMigrator, 19, 20)
|
||||
.with(CollectionMigrator, 20, 21)
|
||||
.with(CollapsedGroupingsMigrator, 21, CURRENT_VERSION);
|
||||
.with(CollapsedGroupingsMigrator, 21, 22)
|
||||
.with(MoveBiometricPromptsToStateProviders, 22, CURRENT_VERSION);
|
||||
}
|
||||
|
||||
export async function currentVersion(
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import {
|
||||
MoveBiometricPromptsToStateProviders,
|
||||
DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
PROMPT_AUTOMATICALLY,
|
||||
} from "./23-move-biometric-prompts-to-state-providers";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
disableAutoBiometricsPrompt: false,
|
||||
dismissedBiometricRequirePasswordOnStartCallout: true,
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
"user_user-1_biometricSettings_dismissedBiometricRequirePasswordOnStartCallout": true,
|
||||
"user_user-1_biometricSettings_promptAutomatically": "false",
|
||||
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<MigrationHelper>;
|
||||
let sut: MoveBiometricPromptsToStateProviders;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 22);
|
||||
sut = new MoveBiometricPromptsToStateProviders(22, 23);
|
||||
});
|
||||
|
||||
it("should remove biometricUnlock, dismissedBiometricRequirePasswordOnStartCallout, and biometricEncryptionClientKeyHalf 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("should set dismissedBiometricRequirePasswordOnStartCallout value for account that have it", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not call extra setToUser", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 23);
|
||||
sut = new MoveBiometricPromptsToStateProviders(22, 23);
|
||||
});
|
||||
|
||||
it.each([DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT, PROMPT_AUTOMATICALLY])(
|
||||
"should null out new values %s",
|
||||
async (keyDefinition) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinition, null);
|
||||
},
|
||||
);
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
disableAutoBiometricsPrompt: false,
|
||||
dismissedBiometricRequirePasswordOnStartCallout: true,
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["user-2", "user-3"])(
|
||||
"should not try to restore values to missing accounts",
|
||||
async (userId) => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith(userId, any());
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
settings?: {
|
||||
disableAutoBiometricsPrompt?: boolean;
|
||||
dismissedBiometricRequirePasswordOnStartCallout?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// prompt cancelled is refreshed on every app start/quit/unlock, so we don't need to migrate it
|
||||
|
||||
export const DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT: KeyDefinitionLike = {
|
||||
key: "dismissedBiometricRequirePasswordOnStartCallout",
|
||||
stateDefinition: { name: "biometricSettings" },
|
||||
};
|
||||
|
||||
export const PROMPT_AUTOMATICALLY: KeyDefinitionLike = {
|
||||
key: "promptAutomatically",
|
||||
stateDefinition: { name: "biometricSettings" },
|
||||
};
|
||||
|
||||
export class MoveBiometricPromptsToStateProviders extends Migrator<22, 23> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const legacyAccounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(
|
||||
legacyAccounts.map(async ({ userId, account }) => {
|
||||
if (account == null) {
|
||||
return;
|
||||
}
|
||||
// Move account data
|
||||
|
||||
if (account?.settings?.dismissedBiometricRequirePasswordOnStartCallout != null) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
account.settings.dismissedBiometricRequirePasswordOnStartCallout,
|
||||
);
|
||||
}
|
||||
|
||||
if (account?.settings?.disableAutoBiometricsPrompt != null) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
PROMPT_AUTOMATICALLY,
|
||||
!account.settings.disableAutoBiometricsPrompt,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete old account data
|
||||
delete account?.settings?.dismissedBiometricRequirePasswordOnStartCallout;
|
||||
delete account?.settings?.disableAutoBiometricsPrompt;
|
||||
await helper.set(userId, account);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
async function rollbackUser(userId: string, account: ExpectedAccountType) {
|
||||
let updatedAccount = false;
|
||||
|
||||
const userDismissed = await helper.getFromUser<boolean>(
|
||||
userId,
|
||||
DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
);
|
||||
|
||||
if (userDismissed) {
|
||||
account ??= {};
|
||||
account.settings ??= {};
|
||||
|
||||
updatedAccount = true;
|
||||
account.settings.dismissedBiometricRequirePasswordOnStartCallout = userDismissed;
|
||||
await helper.setToUser(userId, DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT, null);
|
||||
}
|
||||
|
||||
const userPromptAutomatically = await helper.getFromUser<boolean>(
|
||||
userId,
|
||||
PROMPT_AUTOMATICALLY,
|
||||
);
|
||||
|
||||
if (userPromptAutomatically != null) {
|
||||
account ??= {};
|
||||
account.settings ??= {};
|
||||
|
||||
updatedAccount = true;
|
||||
account.settings.disableAutoBiometricsPrompt = !userPromptAutomatically;
|
||||
await helper.setToUser(userId, PROMPT_AUTOMATICALLY, null);
|
||||
}
|
||||
|
||||
if (updatedAccount) {
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user