mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 23:33:31 +00:00
[PM-8836] Move ownership of biometrics to key-management (#10818)
* Move ownership of biometrics to key-management * Move biometrics ipc ownership to km * Move further files to km; split off preload / ipc to km * Fix linting * Fix linting * Fix tests * Fix tests * Update .github/CODEOWNERS Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> * Update .github/CODEOWNERS Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> * Change ownership of native messaging to key-management * Move biometrics to libs/key-management * Add README to capital whitelist * Update package-lock.json * Move km to key-management * Move km to key-management * Fix build for cli * Import fixes * Apply prettier fix * Fix test * Import fixes * Import fixes * Update libs/key-management/README.md Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> * Update libs/key-management/package.json Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> * Update lock file * Change imports to top level km package --------- Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>
This commit is contained in:
@@ -1,311 +0,0 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { makeEncString, trackEmissions } from "../../../spec";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { FakeGlobalState, FakeSingleUserState } from "../../../spec/fake-state";
|
||||
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
||||
import { UserId } from "../../types/guid";
|
||||
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,
|
||||
FINGERPRINT_VALIDATED,
|
||||
PROMPT_AUTOMATICALLY,
|
||||
PROMPT_CANCELLED,
|
||||
REQUIRE_PASSWORD_ON_START,
|
||||
} from "./biometric.state";
|
||||
|
||||
describe("BiometricStateService", () => {
|
||||
let sut: BiometricStateService;
|
||||
const userId = "userId" as UserId;
|
||||
const encClientKeyHalf = makeEncString();
|
||||
const encryptedClientKeyHalf = encClientKeyHalf.encryptedString;
|
||||
let accountService: FakeAccountService;
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
sut = new DefaultBiometricStateService(stateProvider);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("requirePasswordOnStart$", () => {
|
||||
it("emits when the require password on start state changes", async () => {
|
||||
const state = stateProvider.activeUser.getFake(REQUIRE_PASSWORD_ON_START);
|
||||
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("emits when the encryptedClientKeyHalf state changes", async () => {
|
||||
const state = stateProvider.activeUser.getFake(ENCRYPTED_CLIENT_KEY_HALF);
|
||||
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("fingerprintValidated$", () => {
|
||||
it("emits when the fingerprint validated state changes", async () => {
|
||||
const state = stateProvider.global.getFake(FINGERPRINT_VALIDATED);
|
||||
state.stateSubject.next(undefined);
|
||||
|
||||
expect(await firstValueFrom(sut.fingerprintValidated$)).toBe(false);
|
||||
|
||||
state.stateSubject.next(true);
|
||||
|
||||
expect(await firstValueFrom(sut.fingerprintValidated$)).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setEncryptedClientKeyHalf", () => {
|
||||
it("updates encryptedClientKeyHalf$", async () => {
|
||||
await sut.setEncryptedClientKeyHalf(encClientKeyHalf);
|
||||
|
||||
expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toEqual(encClientKeyHalf);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setRequirePasswordOnStart", () => {
|
||||
it("updates the requirePasswordOnStart$", async () => {
|
||||
await sut.setRequirePasswordOnStart(true);
|
||||
|
||||
expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes the encryptedClientKeyHalf when the set value is false", async () => {
|
||||
await sut.setEncryptedClientKeyHalf(encClientKeyHalf, userId);
|
||||
await sut.setRequirePasswordOnStart(false);
|
||||
|
||||
const keyHalfState = stateProvider.getUser(
|
||||
userId,
|
||||
ENCRYPTED_CLIENT_KEY_HALF,
|
||||
) as FakeSingleUserState<EncryptedString>;
|
||||
expect(await firstValueFrom(keyHalfState.state$)).toBe(null);
|
||||
expect(keyHalfState.nextMock).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("does not remove the encryptedClientKeyHalf when the value is true", async () => {
|
||||
await sut.setEncryptedClientKeyHalf(encClientKeyHalf);
|
||||
await sut.setRequirePasswordOnStart(true);
|
||||
|
||||
expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toEqual(encClientKeyHalf);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRequirePasswordOnStart", () => {
|
||||
it("returns the requirePasswordOnStart state value", async () => {
|
||||
stateProvider.singleUser.mockFor(userId, REQUIRE_PASSWORD_ON_START, true);
|
||||
|
||||
expect(await sut.getRequirePasswordOnStart(userId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("require password on start callout", () => {
|
||||
it("is false when not set", async () => {
|
||||
expect(await firstValueFrom(sut.dismissedRequirePasswordOnStartCallout$)).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when set", async () => {
|
||||
await sut.setDismissedRequirePasswordOnStartCallout();
|
||||
|
||||
expect(await firstValueFrom(sut.dismissedRequirePasswordOnStartCallout$)).toBe(true);
|
||||
});
|
||||
|
||||
it("updates disk state when called", async () => {
|
||||
await sut.setDismissedRequirePasswordOnStartCallout();
|
||||
|
||||
expect(
|
||||
stateProvider.activeUser.getFake(DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT).nextMock,
|
||||
).toHaveBeenCalledWith([userId, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPromptCancelled", () => {
|
||||
let existingState: Record<UserId, boolean>;
|
||||
|
||||
beforeEach(() => {
|
||||
existingState = { ["otherUser" as UserId]: false };
|
||||
stateProvider.global.getFake(PROMPT_CANCELLED).stateSubject.next(existingState);
|
||||
});
|
||||
|
||||
test("observable is updated", async () => {
|
||||
await sut.setUserPromptCancelled();
|
||||
|
||||
expect(await firstValueFrom(sut.promptCancelled$)).toBe(true);
|
||||
});
|
||||
|
||||
it("updates state", async () => {
|
||||
await sut.setUserPromptCancelled();
|
||||
|
||||
const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock;
|
||||
expect(nextMock).toHaveBeenCalledWith({ ...existingState, [userId]: true });
|
||||
expect(nextMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws when called with no active user", async () => {
|
||||
await accountService.switchAccount(null);
|
||||
await expect(sut.setUserPromptCancelled()).rejects.toThrow(
|
||||
"Cannot update biometric prompt cancelled state without an active user",
|
||||
);
|
||||
const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock;
|
||||
expect(nextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetAllPromptCancelled", () => {
|
||||
it("deletes all prompt cancelled state", async () => {
|
||||
await sut.resetAllPromptCancelled();
|
||||
|
||||
const nextMock = stateProvider.global.getFake(PROMPT_CANCELLED).nextMock;
|
||||
expect(nextMock).toHaveBeenCalledWith(null);
|
||||
expect(nextMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates observable to false", async () => {
|
||||
const emissions = trackEmissions(sut.promptCancelled$);
|
||||
|
||||
await sut.setUserPromptCancelled();
|
||||
|
||||
await sut.resetAllPromptCancelled();
|
||||
|
||||
expect(emissions).toEqual([false, true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetUserPromptCancelled", () => {
|
||||
let existingState: Record<UserId, boolean>;
|
||||
let state: FakeGlobalState<Record<UserId, boolean>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await accountService.switchAccount(userId);
|
||||
existingState = { [userId]: true, ["otherUser" as UserId]: false };
|
||||
state = stateProvider.global.getFake(PROMPT_CANCELLED);
|
||||
state.stateSubject.next(existingState);
|
||||
});
|
||||
|
||||
it("deletes specified user prompt cancelled state", async () => {
|
||||
await sut.resetUserPromptCancelled("otherUser" as UserId);
|
||||
|
||||
expect(state.nextMock).toHaveBeenCalledWith({ [userId]: true });
|
||||
expect(state.nextMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deletes active user when called with no user", async () => {
|
||||
await sut.resetUserPromptCancelled();
|
||||
|
||||
expect(state.nextMock).toHaveBeenCalledWith({ ["otherUser" as UserId]: false });
|
||||
expect(state.nextMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates observable to false", async () => {
|
||||
const emissions = trackEmissions(sut.promptCancelled$);
|
||||
|
||||
await sut.resetUserPromptCancelled();
|
||||
|
||||
expect(emissions).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPromptAutomatically", () => {
|
||||
test("observable is updated", async () => {
|
||||
await sut.setPromptAutomatically(true);
|
||||
|
||||
expect(await firstValueFrom(sut.promptAutomatically$)).toBe(true);
|
||||
});
|
||||
|
||||
it("updates state", async () => {
|
||||
await sut.setPromptAutomatically(true);
|
||||
|
||||
const nextMock = stateProvider.activeUser.getFake(PROMPT_AUTOMATICALLY).nextMock;
|
||||
expect(nextMock).toHaveBeenCalledWith([userId, true]);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFingerprintValidated", () => {
|
||||
it("updates fingerprintValidated$", async () => {
|
||||
await sut.setFingerprintValidated(true);
|
||||
|
||||
expect(await firstValueFrom(sut.fingerprintValidated$)).toBe(true);
|
||||
});
|
||||
|
||||
it("updates state", async () => {
|
||||
await sut.setFingerprintValidated(true);
|
||||
|
||||
expect(stateProvider.global.getFake(FINGERPRINT_VALIDATED).nextMock).toHaveBeenCalledWith(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,277 +0,0 @@
|
||||
import { Observable, firstValueFrom, map, combineLatest } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { EncryptedString, EncString } from "../models/domain/enc-string";
|
||||
import { ActiveUserState, GlobalState, StateProvider } from "../state";
|
||||
|
||||
import {
|
||||
BIOMETRIC_UNLOCK_ENABLED,
|
||||
ENCRYPTED_CLIENT_KEY_HALF,
|
||||
REQUIRE_PASSWORD_ON_START,
|
||||
DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
PROMPT_AUTOMATICALLY,
|
||||
PROMPT_CANCELLED,
|
||||
FINGERPRINT_VALIDATED,
|
||||
} from "./biometric.state";
|
||||
|
||||
export abstract class BiometricStateService {
|
||||
/**
|
||||
* `true` if the currently active user has elected to store a biometric key to unlock their vault.
|
||||
*/
|
||||
abstract 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.
|
||||
*
|
||||
* Tracks the currently active user
|
||||
*/
|
||||
abstract encryptedClientKeyHalf$: Observable<EncString | undefined>;
|
||||
/**
|
||||
* whether or not a password is required on first unlock after opening the application
|
||||
*
|
||||
* tracks the currently active user
|
||||
*/
|
||||
abstract 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.
|
||||
*/
|
||||
abstract dismissedRequirePasswordOnStartCallout$: Observable<boolean>;
|
||||
/**
|
||||
* Whether the user has cancelled the biometric prompt.
|
||||
*
|
||||
* tracks the currently active user
|
||||
*/
|
||||
abstract promptCancelled$: Observable<boolean>;
|
||||
/**
|
||||
* Whether the user has elected to automatically prompt for biometrics.
|
||||
*
|
||||
* tracks the currently active user
|
||||
*/
|
||||
abstract promptAutomatically$: Observable<boolean>;
|
||||
/**
|
||||
* Whether or not IPC fingerprint has been validated by the user this session.
|
||||
*/
|
||||
abstract fingerprintValidated$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Updates the require password on start state for the currently active user.
|
||||
*
|
||||
* If false, the encrypted client key half will be removed.
|
||||
* @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>;
|
||||
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.
|
||||
*/
|
||||
abstract setUserPromptCancelled(): Promise<void>;
|
||||
/**
|
||||
* Resets the given user's state to reflect that they haven't cancelled the biometric prompt.
|
||||
* @param userId the user to reset the prompt cancelled state for. If not provided, the currently active user will be used.
|
||||
*/
|
||||
abstract resetUserPromptCancelled(userId?: UserId): Promise<void>;
|
||||
/**
|
||||
* Resets all user's state to reflect that they haven't cancelled the biometric prompt.
|
||||
*/
|
||||
abstract resetAllPromptCancelled(): 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>;
|
||||
/**
|
||||
* Updates whether or not IPC has been validated by the user this session
|
||||
* @param validated the value to save
|
||||
*/
|
||||
abstract setFingerprintValidated(validated: boolean): Promise<void>;
|
||||
|
||||
abstract logout(userId: UserId): Promise<void>;
|
||||
}
|
||||
|
||||
export class DefaultBiometricStateService implements BiometricStateService {
|
||||
private biometricUnlockEnabledState: ActiveUserState<boolean>;
|
||||
private requirePasswordOnStartState: ActiveUserState<boolean>;
|
||||
private encryptedClientKeyHalfState: ActiveUserState<EncryptedString | undefined>;
|
||||
private dismissedRequirePasswordOnStartCalloutState: ActiveUserState<boolean>;
|
||||
private promptCancelledState: GlobalState<Record<UserId, boolean>>;
|
||||
private promptAutomaticallyState: ActiveUserState<boolean>;
|
||||
private fingerprintValidatedState: GlobalState<boolean>;
|
||||
biometricUnlockEnabled$: Observable<boolean>;
|
||||
encryptedClientKeyHalf$: Observable<EncString | undefined>;
|
||||
requirePasswordOnStart$: Observable<boolean>;
|
||||
dismissedRequirePasswordOnStartCallout$: Observable<boolean>;
|
||||
promptCancelled$: Observable<boolean>;
|
||||
promptAutomatically$: Observable<boolean>;
|
||||
fingerprintValidated$: 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),
|
||||
);
|
||||
|
||||
this.encryptedClientKeyHalfState = this.stateProvider.getActive(ENCRYPTED_CLIENT_KEY_HALF);
|
||||
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(Boolean));
|
||||
|
||||
this.promptCancelledState = this.stateProvider.getGlobal(PROMPT_CANCELLED);
|
||||
this.promptCancelled$ = combineLatest([
|
||||
this.stateProvider.activeUserId$,
|
||||
this.promptCancelledState.state$,
|
||||
]).pipe(
|
||||
map(([userId, record]) => {
|
||||
return record?.[userId] ?? false;
|
||||
}),
|
||||
);
|
||||
this.promptAutomaticallyState = this.stateProvider.getActive(PROMPT_AUTOMATICALLY);
|
||||
this.promptAutomatically$ = this.promptAutomaticallyState.state$.pipe(map(Boolean));
|
||||
|
||||
this.fingerprintValidatedState = this.stateProvider.getGlobal(FINGERPRINT_VALIDATED);
|
||||
this.fingerprintValidated$ = this.fingerprintValidatedState.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> {
|
||||
let currentActiveId: UserId;
|
||||
await this.requirePasswordOnStartState.update(
|
||||
(_, [userId]) => {
|
||||
currentActiveId = userId;
|
||||
return value;
|
||||
},
|
||||
{
|
||||
combineLatestWith: this.requirePasswordOnStartState.combinedState$,
|
||||
},
|
||||
);
|
||||
if (!value) {
|
||||
await this.removeEncryptedClientKeyHalf(currentActiveId);
|
||||
}
|
||||
}
|
||||
|
||||
async setEncryptedClientKeyHalf(encryptedKeyHalf: EncString, userId?: UserId): Promise<void> {
|
||||
const value = encryptedKeyHalf?.encryptedString ?? null;
|
||||
if (userId) {
|
||||
await this.stateProvider.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF).update(() => value);
|
||||
} else {
|
||||
await this.encryptedClientKeyHalfState.update(() => value);
|
||||
}
|
||||
}
|
||||
|
||||
async removeEncryptedClientKeyHalf(userId: UserId): Promise<void> {
|
||||
await this.stateProvider.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF).update(() => null);
|
||||
}
|
||||
|
||||
async getRequirePasswordOnStart(userId: UserId): Promise<boolean> {
|
||||
return !!(await firstValueFrom(
|
||||
this.stateProvider.getUser(userId, REQUIRE_PASSWORD_ON_START).state$,
|
||||
));
|
||||
}
|
||||
|
||||
async getEncryptedClientKeyHalf(userId: UserId): Promise<EncString> {
|
||||
return await firstValueFrom(
|
||||
this.stateProvider
|
||||
.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF)
|
||||
.state$.pipe(map(encryptedClientKeyHalfToEncString)),
|
||||
);
|
||||
}
|
||||
|
||||
async logout(userId: UserId): Promise<void> {
|
||||
await this.stateProvider.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF).update(() => null);
|
||||
await this.resetUserPromptCancelled(userId);
|
||||
// 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 resetUserPromptCancelled(userId: UserId): Promise<void> {
|
||||
await this.stateProvider.getGlobal(PROMPT_CANCELLED).update(
|
||||
(data, activeUserId) => {
|
||||
delete data[userId ?? activeUserId];
|
||||
return data;
|
||||
},
|
||||
{
|
||||
combineLatestWith: this.stateProvider.activeUserId$,
|
||||
shouldUpdate: (data, activeUserId) => data?.[userId ?? activeUserId] != null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async setUserPromptCancelled(): Promise<void> {
|
||||
await this.promptCancelledState.update(
|
||||
(record, userId) => {
|
||||
record ??= {};
|
||||
record[userId] = true;
|
||||
return record;
|
||||
},
|
||||
{
|
||||
combineLatestWith: this.stateProvider.activeUserId$,
|
||||
shouldUpdate: (_, userId) => {
|
||||
if (userId == null) {
|
||||
throw new Error(
|
||||
"Cannot update biometric prompt cancelled state without an active user",
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async resetAllPromptCancelled(): Promise<void> {
|
||||
await this.promptCancelledState.update(() => null);
|
||||
}
|
||||
|
||||
async setPromptAutomatically(prompt: boolean): Promise<void> {
|
||||
await this.promptAutomaticallyState.update(() => prompt);
|
||||
}
|
||||
|
||||
async setFingerprintValidated(validated: boolean): Promise<void> {
|
||||
await this.fingerprintValidatedState.update(() => validated);
|
||||
}
|
||||
}
|
||||
|
||||
function encryptedClientKeyHalfToEncString(
|
||||
encryptedKeyHalf: EncryptedString | undefined,
|
||||
): EncString {
|
||||
return encryptedKeyHalf == null ? null : new EncString(encryptedKeyHalf);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* The biometrics service is used to provide access to the status of and access to biometric functionality on the platforms.
|
||||
*/
|
||||
export abstract class BiometricsService {
|
||||
/**
|
||||
* Check if the platform supports biometric authentication.
|
||||
*/
|
||||
abstract supportsBiometric(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Checks whether biometric unlock is currently available at the moment (e.g. if the laptop lid is shut, biometric unlock may not be available)
|
||||
*/
|
||||
abstract isBiometricUnlockAvailable(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Performs biometric authentication
|
||||
*/
|
||||
abstract authenticateBiometric(): Promise<boolean>;
|
||||
/**
|
||||
* Determine whether biometrics support requires going through a setup process.
|
||||
* This is currently only needed on Linux.
|
||||
*
|
||||
* @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place)
|
||||
*/
|
||||
abstract biometricsNeedsSetup(): Promise<boolean>;
|
||||
/**
|
||||
* Determine whether biometrics support can be automatically setup, or requires user interaction.
|
||||
* Auto-setup is prevented by sandboxed environments, such as Snap and Flatpak.
|
||||
*
|
||||
* @returns true if biometrics support can be automatically setup, false if it requires user interaction.
|
||||
*/
|
||||
abstract biometricsSupportsAutoSetup(): Promise<boolean>;
|
||||
/**
|
||||
* Start automatic biometric setup, which places the required configuration files / changes the required settings.
|
||||
*/
|
||||
abstract biometricsSetup(): Promise<void>;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { EncryptedString } from "../models/domain/enc-string";
|
||||
import { KeyDefinition, UserKeyDefinition } from "../state";
|
||||
|
||||
import {
|
||||
BIOMETRIC_UNLOCK_ENABLED,
|
||||
DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT,
|
||||
ENCRYPTED_CLIENT_KEY_HALF,
|
||||
FINGERPRINT_VALIDATED,
|
||||
PROMPT_AUTOMATICALLY,
|
||||
PROMPT_CANCELLED,
|
||||
REQUIRE_PASSWORD_ON_START,
|
||||
} from "./biometric.state";
|
||||
|
||||
describe.each([
|
||||
[ENCRYPTED_CLIENT_KEY_HALF, "encryptedClientKeyHalf"],
|
||||
[DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT, true],
|
||||
[PROMPT_CANCELLED, { userId1: true, userId2: false }],
|
||||
[PROMPT_AUTOMATICALLY, true],
|
||||
[REQUIRE_PASSWORD_ON_START, true],
|
||||
[BIOMETRIC_UNLOCK_ENABLED, true],
|
||||
[FINGERPRINT_VALIDATED, true],
|
||||
])(
|
||||
"deserializes state %s",
|
||||
(
|
||||
...args:
|
||||
| [UserKeyDefinition<EncryptedString>, EncryptedString]
|
||||
| [UserKeyDefinition<boolean>, boolean]
|
||||
| [KeyDefinition<boolean>, boolean]
|
||||
) => {
|
||||
function testDeserialization<T>(
|
||||
keyDefinition: UserKeyDefinition<T> | 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;
|
||||
testDeserialization(keyDefinition, state);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,93 +0,0 @@
|
||||
import { UserId } from "../../types/guid";
|
||||
import { EncryptedString } from "../models/domain/enc-string";
|
||||
import { KeyDefinition, BIOMETRIC_SETTINGS_DISK, UserKeyDefinition } from "../state";
|
||||
|
||||
/**
|
||||
* Indicates whether the user elected to store a biometric key to unlock their vault.
|
||||
*/
|
||||
export const BIOMETRIC_UNLOCK_ENABLED = new UserKeyDefinition<boolean>(
|
||||
BIOMETRIC_SETTINGS_DISK,
|
||||
"biometricUnlockEnabled",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Boolean indicating the user has elected to require a password to use their biometric key upon starting the application.
|
||||
*
|
||||
* A true setting controls whether {@link ENCRYPTED_CLIENT_KEY_HALF} is set.
|
||||
*/
|
||||
export const REQUIRE_PASSWORD_ON_START = new UserKeyDefinition<boolean>(
|
||||
BIOMETRIC_SETTINGS_DISK,
|
||||
"requirePasswordOnStart",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* For operating systems without application-level key storage, this key half is concatenated with a signature
|
||||
* provided by the OS and used to encrypt the biometric key prior to storage.
|
||||
*/
|
||||
export const ENCRYPTED_CLIENT_KEY_HALF = new UserKeyDefinition<EncryptedString>(
|
||||
BIOMETRIC_SETTINGS_DISK,
|
||||
"clientKeyHalf",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 UserKeyDefinition<boolean>(
|
||||
BIOMETRIC_SETTINGS_DISK,
|
||||
"dismissedBiometricRequirePasswordOnStartCallout",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 = KeyDefinition.record<boolean, UserId>(
|
||||
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 UserKeyDefinition<boolean>(
|
||||
BIOMETRIC_SETTINGS_DISK,
|
||||
"promptAutomatically",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Stores whether or not IPC handshake has been validated this session.
|
||||
*/
|
||||
export const FINGERPRINT_VALIDATED = new KeyDefinition<boolean>(
|
||||
BIOMETRIC_SETTINGS_DISK,
|
||||
"fingerprintValidated",
|
||||
{
|
||||
deserializer: (obj) => obj,
|
||||
},
|
||||
);
|
||||
@@ -1,5 +1,7 @@
|
||||
import { firstValueFrom, map, Subscription, timeout } from "rxjs";
|
||||
|
||||
import { BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { PinServiceAbstraction } from "../../../../auth/src/common/abstractions";
|
||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
@@ -11,7 +13,6 @@ import { UserId } from "../../types/guid";
|
||||
import { MessagingService } from "../abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
|
||||
import { SystemService as SystemServiceAbstraction } from "../abstractions/system.service";
|
||||
import { BiometricStateService } from "../biometrics/biometric-state.service";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { ScheduledTaskNames } from "../scheduling/scheduled-task-name.enum";
|
||||
import { TaskSchedulerService } from "../scheduling/task-scheduler.service";
|
||||
|
||||
Reference in New Issue
Block a user