1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 23:33:31 +00:00

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 <acoroiu@bitwarden.com>

* Fix merge commit

* Fix test names

---------

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
This commit is contained in:
Matt Gibson
2024-03-01 09:17:06 -06:00
committed by GitHub
parent 53b547de7c
commit 5677d6265e
26 changed files with 443 additions and 79 deletions

View File

@@ -74,8 +74,6 @@ export abstract class StateService<T extends Account = Account> {
setAutoConfirmFingerprints: (value: boolean, options?: StorageOptions) => Promise<void>;
getBiometricFingerprintValidated: (options?: StorageOptions) => Promise<boolean>;
setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise<void>;
getBiometricUnlock: (options?: StorageOptions) => Promise<boolean>;
setBiometricUnlock: (value: boolean, options?: StorageOptions) => Promise<void>;
getCanAccessPremium: (options?: StorageOptions) => Promise<boolean>;
getHasPremiumPersonally: (options?: StorageOptions) => Promise<boolean>;
setHasPremiumPersonally: (value: boolean, options?: StorageOptions) => Promise<void>;

View File

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

View File

@@ -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<boolean>; // 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<void>;
/**
* 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<void>;
/**
* Gets the biometric unlock enabled state for the given user.
* @param userId user Id to check
*/
abstract getBiometricUnlockEnabled(userId: UserId): Promise<boolean>;
abstract setEncryptedClientKeyHalf(encryptedKeyHalf: EncString, userId?: UserId): Promise<void>;
abstract getEncryptedClientKeyHalf(userId: UserId): Promise<EncString>;
abstract getRequirePasswordOnStart(userId: UserId): Promise<boolean>;
@@ -78,11 +93,13 @@ export abstract class BiometricStateService {
}
export class DefaultBiometricStateService implements BiometricStateService {
private biometricUnlockEnabledState: ActiveUserState<boolean>;
private requirePasswordOnStartState: ActiveUserState<boolean>;
private encryptedClientKeyHalfState: ActiveUserState<EncryptedString | undefined>;
private dismissedRequirePasswordOnStartCalloutState: ActiveUserState<boolean>;
private promptCancelledState: ActiveUserState<boolean>;
private promptAutomaticallyState: ActiveUserState<boolean>;
biometricUnlockEnabled$: Observable<boolean>;
encryptedClientKeyHalf$: Observable<EncString | undefined>;
requirePasswordOnStart$: Observable<boolean>;
dismissedRequirePasswordOnStartCallout$: Observable<boolean>;
@@ -90,6 +107,9 @@ export class DefaultBiometricStateService implements BiometricStateService {
promptAutomatically$: Observable<boolean>;
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<void> {
await this.biometricUnlockEnabledState.update(() => enabled);
}
async getBiometricUnlockEnabled(userId: UserId): Promise<boolean> {
return await firstValueFrom(
this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)),
);
}
async setRequirePasswordOnStart(value: boolean): Promise<void> {

View File

@@ -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>, EncryptedString] | [KeyDefinition<boolean>, boolean]
) => {
function testDeserialization<T>(keyDefinition: KeyDefinition<T>, 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);
});
},
);

View File

@@ -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<boolean>(
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.
*

View File

@@ -200,7 +200,6 @@ export class AccountProfile {
export class AccountSettings {
autoConfirmFingerPrints?: boolean;
biometricUnlock?: boolean;
defaultUriMatch?: UriMatchType;
disableGa?: boolean;
dontShowCardsCurrentTab?: boolean;

View File

@@ -378,24 +378,6 @@ export class StateService<
);
}
async getBiometricUnlock(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.settings?.biometricUnlock ?? false
);
}
async setBiometricUnlock(value: boolean, options?: StorageOptions): Promise<void> {
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<boolean> {
if (!(await this.getIsAuthenticated(options))) {
return false;