1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 23:33:31 +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:
Matt Gibson
2024-02-23 09:21:18 -05:00
committed by GitHub
parent 19a373d87e
commit 9775e77079
32 changed files with 549 additions and 180 deletions

View File

@@ -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>;

View File

@@ -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);
});
});
});

View File

@@ -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);
}
}

View File

@@ -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);
}
});
},
);

View File

@@ -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,
},
);

View File

@@ -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>();

View File

@@ -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())))