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; // 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; /** * whether or not a password is required on first unlock after opening the application * * tracks the currently active user */ abstract requirePasswordOnStart$: Observable; /** * 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; /** * Whether the user has cancelled the biometric prompt. * * tracks the currently active user */ abstract promptCancelled$: Observable; /** * Whether the user has elected to automatically prompt for biometrics. * * tracks the currently active user */ abstract promptAutomatically$: Observable; /** * Whether or not IPC fingerprint has been validated by the user this session. */ abstract fingerprintValidated$: Observable; /** * 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; /** * 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; /** * Gets the biometric unlock enabled state for the given user. * @param userId user Id to check */ abstract getBiometricUnlockEnabled(userId: UserId): Promise; abstract setEncryptedClientKeyHalf(encryptedKeyHalf: EncString, userId?: UserId): Promise; abstract getEncryptedClientKeyHalf(userId: UserId): Promise; abstract getRequirePasswordOnStart(userId: UserId): Promise; abstract removeEncryptedClientKeyHalf(userId: UserId): Promise; /** * Updates the active user's state to reflect that they've been warned about requiring password on start. */ abstract setDismissedRequirePasswordOnStartCallout(): Promise; /** * Updates the active user's state to reflect that they've cancelled the biometric prompt. */ abstract setUserPromptCancelled(): Promise; /** * 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; /** * Resets all user's state to reflect that they haven't cancelled the biometric prompt. */ abstract resetAllPromptCancelled(): Promise; /** * 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; /** * Updates whether or not IPC has been validated by the user this session * @param validated the value to save */ abstract setFingerprintValidated(validated: boolean): Promise; abstract logout(userId: UserId): Promise; } export class DefaultBiometricStateService implements BiometricStateService { private biometricUnlockEnabledState: ActiveUserState; private requirePasswordOnStartState: ActiveUserState; private encryptedClientKeyHalfState: ActiveUserState; private dismissedRequirePasswordOnStartCalloutState: ActiveUserState; private promptCancelledState: GlobalState>; private promptAutomaticallyState: ActiveUserState; private fingerprintValidatedState: GlobalState; biometricUnlockEnabled$: Observable; encryptedClientKeyHalf$: Observable; requirePasswordOnStart$: Observable; dismissedRequirePasswordOnStartCallout$: Observable; promptCancelled$: Observable; promptAutomatically$: Observable; fingerprintValidated$: Observable; 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 { await this.biometricUnlockEnabledState.update(() => enabled); } async getBiometricUnlockEnabled(userId: UserId): Promise { return await firstValueFrom( this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)), ); } async setRequirePasswordOnStart(value: boolean): Promise { 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 { 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 { await this.stateProvider.getUser(userId, ENCRYPTED_CLIENT_KEY_HALF).update(() => null); } async getRequirePasswordOnStart(userId: UserId): Promise { return !!(await firstValueFrom( this.stateProvider.getUser(userId, REQUIRE_PASSWORD_ON_START).state$, )); } async getEncryptedClientKeyHalf(userId: UserId): Promise { return await firstValueFrom( this.stateProvider .getUser(userId, ENCRYPTED_CLIENT_KEY_HALF) .state$.pipe(map(encryptedClientKeyHalfToEncString)), ); } async logout(userId: UserId): Promise { 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 { await this.dismissedRequirePasswordOnStartCalloutState.update(() => true); } async resetUserPromptCancelled(userId: UserId): Promise { 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 { 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 { await this.promptCancelledState.update(() => null); } async setPromptAutomatically(prompt: boolean): Promise { await this.promptAutomaticallyState.update(() => prompt); } async setFingerprintValidated(validated: boolean): Promise { await this.fingerprintValidatedState.update(() => validated); } } function encryptedClientKeyHalfToEncString( encryptedKeyHalf: EncryptedString | undefined, ): EncString { return encryptedKeyHalf == null ? null : new EncString(encryptedKeyHalf); }