mirror of
https://github.com/bitwarden/browser
synced 2025-12-20 02:03:39 +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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user