1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[PM-5537] Biometric State Service (#7761)

* Create state for biometric client key halves

* Move enc string util to central utils

* Provide biometric state through service

* Use biometric state to track client key half

* Create migration for client key half

* Ensure client key half is removed on logout

* Remove account data for client key half

* Remove unnecessary key definition likes

* Remove moved state from account

* Fix null-conditional operator failure

* Simplify migration

* Remove lame test

* Fix test type

* Add migrator

* Prefer userKey when legacy not needed

* Fix tests
This commit is contained in:
Matt Gibson
2024-02-05 13:02:28 -05:00
committed by GitHub
parent 99f18c9666
commit 414ee2563f
25 changed files with 547 additions and 155 deletions

View File

@@ -0,0 +1,61 @@
import { firstValueFrom } from "rxjs";
import { makeEncString } from "../../../spec";
import { mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeStateProvider } from "../../../spec/fake-state-provider";
import { UserId } from "../../types/guid";
import { BiometricStateService, DefaultBiometricStateService } from "./biometric-state.service";
import { ENCRYPTED_CLIENT_KEY_HALF } from "./biometric.state";
describe("BiometricStateService", () => {
let sut: BiometricStateService;
const userId = "userId" as UserId;
const encClientKeyHalf = makeEncString();
const encryptedClientKeyHalf = encClientKeyHalf.encryptedString;
const accountService = mockAccountServiceWith(userId);
let stateProvider: FakeStateProvider;
beforeEach(() => {
stateProvider = new FakeStateProvider(accountService);
sut = new DefaultBiometricStateService(stateProvider);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("requirePasswordOnStart$", () => {
it("should be false when encryptedClientKeyHalf is undefined", async () => {
stateProvider.activeUser.getFake(ENCRYPTED_CLIENT_KEY_HALF).nextState(undefined);
expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(false);
});
it("should be true when encryptedClientKeyHalf is defined", async () => {
stateProvider.activeUser.getFake(ENCRYPTED_CLIENT_KEY_HALF).nextState(encryptedClientKeyHalf);
expect(await firstValueFrom(sut.requirePasswordOnStart$)).toBe(true);
});
});
describe("encryptedClientKeyHalf$", () => {
it("should track the encryptedClientKeyHalf state", 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);
});
});
describe("setEncryptedClientKeyHalf", () => {
it("should update the encryptedClientKeyHalf$", async () => {
await sut.setEncryptedClientKeyHalf(encClientKeyHalf);
expect(await firstValueFrom(sut.encryptedClientKeyHalf$)).toEqual(encClientKeyHalf);
});
});
});

View File

@@ -0,0 +1,75 @@
import { Observable, firstValueFrom, map } from "rxjs";
import { UserId } from "../../types/guid";
import { EncryptedString, EncString } from "../models/domain/enc-string";
import { ActiveUserState, StateProvider } from "../state";
import { ENCRYPTED_CLIENT_KEY_HALF } from "./biometric.state";
export abstract class BiometricStateService {
/**
* 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
*/
encryptedClientKeyHalf$: Observable<EncString | undefined>;
/**
* whether or not a password is required on first unlock after opening the application
*
* tracks the currently active user
*/
requirePasswordOnStart$: Observable<boolean>;
abstract setEncryptedClientKeyHalf(encryptedKeyHalf: EncString): Promise<void>;
abstract getEncryptedClientKeyHalf(userId: UserId): Promise<EncString>;
abstract getRequirePasswordOnStart(userId: UserId): Promise<boolean>;
abstract removeEncryptedClientKeyHalf(userId: UserId): Promise<void>;
}
export class DefaultBiometricStateService implements BiometricStateService {
private encryptedClientKeyHalfState: ActiveUserState<EncryptedString | undefined>;
encryptedClientKeyHalf$: Observable<EncString | undefined>;
requirePasswordOnStart$: Observable<boolean>;
constructor(private stateProvider: StateProvider) {
this.encryptedClientKeyHalfState = this.stateProvider.getActive(ENCRYPTED_CLIENT_KEY_HALF);
this.encryptedClientKeyHalf$ = this.encryptedClientKeyHalfState.state$.pipe(
map(encryptedClientKeyHalfToEncString),
);
this.requirePasswordOnStart$ = this.encryptedClientKeyHalf$.pipe(map((keyHalf) => !!keyHalf));
}
async setEncryptedClientKeyHalf(encryptedKeyHalf: EncString): Promise<void> {
await this.encryptedClientKeyHalfState.update(() => encryptedKeyHalf?.encryptedString ?? null);
}
async removeEncryptedClientKeyHalf(userId: UserId): Promise<void> {
await this.stateProvider.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF).update(() => null);
}
async getRequirePasswordOnStart(userId: UserId): Promise<boolean> {
if (userId == null) {
return false;
}
return !!(await this.getEncryptedClientKeyHalf(userId));
}
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);
}
}
function encryptedClientKeyHalfToEncString(
encryptedKeyHalf: EncryptedString | undefined,
): EncString {
return encryptedKeyHalf == null ? null : new EncString(encryptedKeyHalf);
}

View File

@@ -0,0 +1,13 @@
import { ENCRYPTED_CLIENT_KEY_HALF } from "./biometric.state";
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);
});
});

View File

@@ -0,0 +1,17 @@
import { EncryptedString } from "../models/domain/enc-string";
import { KeyDefinition, BIOMETRIC_SETTINGS_DISK } from "../state";
/**
* 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 KeyDefinition<EncryptedString>(
BIOMETRIC_SETTINGS_DISK,
"clientKeyHalf",
{
deserializer: (obj) => obj,
},
);

View File

@@ -1,22 +1,12 @@
import { mock } from "jest-mock-extended";
import { makeStaticByteArray } from "../../../../spec";
import { ProviderEncryptedOrganizationKey } from "../../../admin-console/models/domain/encrypted-organization-key";
import { makeEncString, makeStaticByteArray } from "../../../../spec";
import { OrgKey } from "../../../types/key";
import { CryptoService } from "../../abstractions/crypto.service";
import { EncryptionType } from "../../enums";
import { Utils } from "../../misc/utils";
import { EncString } from "../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { USER_ENCRYPTED_ORGANIZATION_KEYS, USER_ORGANIZATION_KEYS } from "./org-keys.state";
function makeEncString(data?: string) {
data ??= Utils.newGuid();
return new EncString(EncryptionType.AesCbc256_HmacSha256_B64, data, "test", "test");
}
ProviderEncryptedOrganizationKey;
describe("encrypted org keys", () => {
const sut = USER_ENCRYPTED_ORGANIZATION_KEYS;

View File

@@ -1,22 +1,15 @@
import { mock } from "jest-mock-extended";
import { makeStaticByteArray } from "../../../../spec";
import { makeEncString, makeStaticByteArray } from "../../../../spec";
import { ProviderId } from "../../../types/guid";
import { ProviderKey, UserPrivateKey } from "../../../types/key";
import { EncryptService } from "../../abstractions/encrypt.service";
import { EncryptionType } from "../../enums";
import { Utils } from "../../misc/utils";
import { EncString, EncryptedString } from "../../models/domain/enc-string";
import { EncryptedString } from "../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { CryptoService } from "../crypto.service";
import { USER_ENCRYPTED_PROVIDER_KEYS, USER_PROVIDER_KEYS } from "./provider-keys.state";
function makeEncString(data?: string) {
data ??= Utils.newGuid();
return new EncString(EncryptionType.AesCbc256_HmacSha256_B64, data, "test", "test");
}
describe("encrypted provider keys", () => {
const sut = USER_ENCRYPTED_PROVIDER_KEYS;

View File

@@ -22,14 +22,16 @@ export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
export const BILLING_BANNERS_DISK = new StateDefinition("billingBanners", "disk");
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
export const GENERATOR_DISK = new StateDefinition("generator", "disk");
export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
// Admin Console
export const ORGANIZATIONS_DISK = new StateDefinition("organizations", "disk");
export const POLICIES_DISK = new StateDefinition("policies", "disk");
export const POLICIES_MEMORY = new StateDefinition("policies", "memory");
export const PROVIDERS_DISK = new StateDefinition("providers", "disk");
//