diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 0b5cd62811..0c537d077d 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -591,6 +591,19 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "cipher" version = "0.4.4" @@ -904,6 +917,7 @@ dependencies = [ "byteorder", "bytes", "cbc", + "chacha20poly1305", "core-foundation", "desktop_objc", "dirs", @@ -923,6 +937,8 @@ dependencies = [ "secmem-proc", "security-framework", "security-framework-sys", + "serde", + "serde_json", "sha2", "ssh-encoding", "ssh-key", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 39c77f5325..3370a940a9 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -27,6 +27,7 @@ bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", re byteorder = "=1.5.0" bytes = "=1.10.1" cbc = "=0.1.2" +chacha20poly1305 = "=0.10.1" core-foundation = "=0.10.1" ctor = "=0.5.0" dirs = "=6.0.0" diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index 36e1a85abc..b7e4c9b7a8 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -26,6 +26,7 @@ bitwarden-russh = { workspace = true } byteorder = { workspace = true } bytes = { workspace = true } cbc = { workspace = true, features = ["alloc"] } +chacha20poly1305 = { workspace = true } dirs = { workspace = true } ed25519 = { workspace = true, features = ["pkcs8"] } futures = { workspace = true } @@ -38,6 +39,8 @@ rsa = { workspace = true } russh-cryptovec = { workspace = true } scopeguard = { workspace = true } secmem-proc = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } sha2 = { workspace = true } ssh-encoding = { workspace = true } ssh-key = { workspace = true, features = [ @@ -64,6 +67,7 @@ windows = { workspace = true, features = [ "Storage_Streams", "Win32_Foundation", "Win32_Security_Credentials", + "Win32_Security_Cryptography", "Win32_System_WinRT", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs new file mode 100644 index 0000000000..e37a101e2a --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs @@ -0,0 +1,33 @@ +use anyhow::Result; + +#[allow(clippy::module_inception)] +#[cfg_attr(target_os = "linux", path = "unimplemented.rs")] +#[cfg_attr(target_os = "macos", path = "unimplemented.rs")] +#[cfg_attr(target_os = "windows", path = "windows.rs")] +mod biometric_v2; + +#[cfg(target_os = "windows")] +pub mod windows_focus; + +pub use biometric_v2::BiometricLockSystem; + +#[allow(async_fn_in_trait)] +pub trait BiometricTrait: Send + Sync { + /// Authenticate the user + async fn authenticate(&self, hwnd: Vec, message: String) -> Result; + /// Check if biometric authentication is available + async fn authenticate_available(&self) -> Result; + /// Enroll a key for persistent unlock. If the implementation does not support persistent enrollment, + /// this function should do nothing. + async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()>; + /// Clear the persistent and ephemeral keys + async fn unenroll(&self, user_id: &str) -> Result<()>; + /// Check if a persistent (survives app restarts and reboots) key is set for a user + async fn has_persistent(&self, user_id: &str) -> Result; + /// Provide a key to be ephemerally held. This should be called on every unlock. + async fn provide_key(&self, user_id: &str, key: &[u8]); + /// Perform biometric unlock and return the key + async fn unlock(&self, user_id: &str, hwnd: Vec) -> Result>; + /// Check if biometric unlock is available based on whether a key is present and whether authentication is possible + async fn unlock_available(&self, user_id: &str) -> Result; +} diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs b/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs new file mode 100644 index 0000000000..1503cfea89 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs @@ -0,0 +1,47 @@ +pub struct BiometricLockSystem {} + +impl BiometricLockSystem { + pub fn new() -> Self { + Self {} + } +} + +impl Default for BiometricLockSystem { + fn default() -> Self { + Self::new() + } +} + +impl super::BiometricTrait for BiometricLockSystem { + async fn authenticate(&self, _hwnd: Vec, _message: String) -> Result { + unimplemented!() + } + + async fn authenticate_available(&self) -> Result { + unimplemented!() + } + + async fn enroll_persistent(&self, _user_id: &str, _key: &[u8]) -> Result<(), anyhow::Error> { + unimplemented!() + } + + async fn provide_key(&self, _user_id: &str, _key: &[u8]) { + unimplemented!() + } + + async fn unlock(&self, _user_id: &str, _hwnd: Vec) -> Result, anyhow::Error> { + unimplemented!() + } + + async fn unlock_available(&self, _user_id: &str) -> Result { + unimplemented!() + } + + async fn has_persistent(&self, _user_id: &str) -> Result { + unimplemented!() + } + + async fn unenroll(&self, _user_id: &str) -> Result<(), anyhow::Error> { + unimplemented!() + } +} diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs new file mode 100644 index 0000000000..479f96312c --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs @@ -0,0 +1,505 @@ +//! This file implements Windows-Hello based biometric unlock. +//! +//! There are two paths implemented here. +//! The former via UV + ephemerally (but protected) keys. This only works after first unlock. +//! The latter via a signing API, that deterministically signs a challenge, from which a windows hello key is derived. This key +//! is used to encrypt the protected key. +//! +//! # Security +//! The security goal is that a locked vault - a running app - cannot be unlocked when the device (user-space) +//! is compromised in this state. +//! +//! ## UV path +//! When first unlocking the app, the app sends the user-key to this module, which holds it in secure memory, +//! protected by DPAPI. This makes it inaccessible to other processes, unless they compromise the system administrator, or kernel. +//! While the app is running this key is held in memory, even if locked. When unlocking, the app will prompt the user via +//! `windows_hello_authenticate` to get a yes/no decision on whether to release the key to the app. +//! Note: Further process isolation is needed here so that code cannot be injected into the running process, which may +//! circumvent DPAPI. +//! +//! ## Sign path +//! In this scenario, when enrolling, the app sends the user-key to this module, which derives the windows hello key +//! with the Windows Hello prompt. This is done by signing a per-user challenge, which produces a deterministic +//! signature which is hashed to obtain a key. This key is used to encrypt and persist the vault unlock key (user key). +//! +//! Since the keychain can be accessed by all user-space processes, the challenge is known to all userspace processes. +//! Therefore, to circumvent the security measure, the attacker would need to create a fake Windows-Hello prompt, and +//! get the user to confirm it. + +use std::sync::{atomic::AtomicBool, Arc}; +use tracing::{debug, warn}; + +use aes::cipher::KeyInit; +use anyhow::{anyhow, Result}; +use chacha20poly1305::{aead::Aead, XChaCha20Poly1305, XNonce}; +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; +use windows::{ + core::{factory, h, Interface, HSTRING}, + Security::{ + Credentials::{ + KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus, + UI::{ + UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability, + }, + }, + Cryptography::CryptographicBuffer, + }, + Storage::Streams::IBuffer, + Win32::{ + System::WinRT::{IBufferByteAccess, IUserConsentVerifierInterop}, + UI::WindowsAndMessaging::GetForegroundWindow, + }, +}; +use windows_future::IAsyncOperation; + +use super::windows_focus::{focus_security_prompt, restore_focus}; +use crate::{ + password::{self, PASSWORD_NOT_FOUND}, + secure_memory::*, +}; + +const KEYCHAIN_SERVICE_NAME: &str = "BitwardenBiometricsV2"; +const CREDENTIAL_NAME: &HSTRING = h!("BitwardenBiometricsV2"); +const CHALLENGE_LENGTH: usize = 16; +const XCHACHA20POLY1305_NONCE_LENGTH: usize = 24; +const XCHACHA20POLY1305_KEY_LENGTH: usize = 32; + +#[derive(serde::Serialize, serde::Deserialize)] +struct WindowsHelloKeychainEntry { + nonce: [u8; XCHACHA20POLY1305_NONCE_LENGTH], + challenge: [u8; CHALLENGE_LENGTH], + wrapped_key: Vec, +} + +/// The Windows OS implementation of the biometric trait. +pub struct BiometricLockSystem { + // The userkeys that are held in memory MUST be protected from memory dumping attacks, to ensure + // locked vaults cannot be unlocked + secure_memory: Arc>, +} + +impl BiometricLockSystem { + pub fn new() -> Self { + Self { + secure_memory: Arc::new(Mutex::new( + crate::secure_memory::dpapi::DpapiSecretKVStore::new(), + )), + } + } +} + +impl Default for BiometricLockSystem { + fn default() -> Self { + Self::new() + } +} + +impl super::BiometricTrait for BiometricLockSystem { + async fn authenticate(&self, _hwnd: Vec, message: String) -> Result { + windows_hello_authenticate(message).await + } + + async fn authenticate_available(&self) -> Result { + match UserConsentVerifier::CheckAvailabilityAsync()?.await? { + UserConsentVerifierAvailability::Available + | UserConsentVerifierAvailability::DeviceBusy => Ok(true), + _ => Ok(false), + } + } + + async fn unenroll(&self, user_id: &str) -> Result<()> { + self.secure_memory.lock().await.remove(user_id); + delete_keychain_entry(user_id).await + } + + async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()> { + // Enrollment works by first generating a random challenge unique to the user / enrollment. Then, + // with the challenge and a Windows-Hello prompt, the "windows hello key" is derived. The windows + // hello key is used to encrypt the key to store with XChaCha20Poly1305. The bundle of nonce, + // challenge and wrapped-key are stored to the keychain + + // Each enrollment (per user) has a unique challenge, so that the windows-hello key is unique + let challenge: [u8; CHALLENGE_LENGTH] = rand::random(); + + // This key is unique to the challenge + let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge).await?; + let (wrapped_key, nonce) = encrypt_data(&windows_hello_key, key)?; + + set_keychain_entry( + user_id, + &WindowsHelloKeychainEntry { + nonce, + challenge, + wrapped_key, + }, + ) + .await + } + + async fn provide_key(&self, user_id: &str, key: &[u8]) { + self.secure_memory + .lock() + .await + .put(user_id.to_string(), key); + } + + async fn unlock(&self, user_id: &str, _hwnd: Vec) -> Result> { + // Allow restoring focus to the previous window (browser) + let previous_active_window = super::windows_focus::get_active_window(); + let _focus_scopeguard = scopeguard::guard((), |_| { + if let Some(hwnd) = previous_active_window { + debug!("Restoring focus to previous window"); + restore_focus(hwnd.0); + } + }); + + let mut secure_memory = self.secure_memory.lock().await; + // If the key is held ephemerally, always use UV API. Only use signing API if the key is not held + // ephemerally but the keychain holds it persistently. + if secure_memory.has(user_id) { + if windows_hello_authenticate("Unlock your vault".to_string()).await? { + secure_memory + .get(user_id) + .clone() + .ok_or_else(|| anyhow!("No key found for user")) + } else { + Err(anyhow!("Authentication failed")) + } + } else { + let keychain_entry = get_keychain_entry(user_id).await?; + let windows_hello_key = + windows_hello_authenticate_with_crypto(&keychain_entry.challenge).await?; + let decrypted_key = decrypt_data( + &windows_hello_key, + &keychain_entry.wrapped_key, + &keychain_entry.nonce, + )?; + // The first unlock already sets the key for subsequent unlocks. The key may again be set externally after unlock finishes. + secure_memory.put(user_id.to_string(), &decrypted_key.clone()); + Ok(decrypted_key) + } + } + + async fn unlock_available(&self, user_id: &str) -> Result { + let secure_memory = self.secure_memory.lock().await; + let has_key = + secure_memory.has(user_id) || has_keychain_entry(user_id).await.unwrap_or(false); + Ok(has_key && self.authenticate_available().await.unwrap_or(false)) + } + + async fn has_persistent(&self, user_id: &str) -> Result { + Ok(get_keychain_entry(user_id).await.is_ok()) + } +} + +/// Get a yes/no authorization without any cryptographic backing. +/// This API has better focusing behavior +async fn windows_hello_authenticate(message: String) -> Result { + debug!( + "[Windows Hello] Authenticating to perform UV with message: {}", + message + ); + + let userconsent_result: IAsyncOperation = unsafe { + // Windows Hello prompt must be in foreground, focused, otherwise the face or fingerprint + // unlock will not work. We get the current foreground window, which will either be the + // Bitwarden desktop app or the browser extension. + let foreground_window = GetForegroundWindow(); + factory::()? + .RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))? + }; + + match userconsent_result.await? { + UserConsentVerificationResult::Verified => Ok(true), + _ => Ok(false), + } +} + +/// Derive the symmetric encryption key from the Windows Hello signature. +/// +/// This works by signing a static challenge string with Windows Hello protected key store. The +/// signed challenge is then hashed using SHA-256 and used as the symmetric encryption key for the +/// Windows Hello protected keys. +/// +/// Windows will only sign the challenge if the user has successfully authenticated with Windows, +/// ensuring user presence. +/// +/// Note: This API has inconsistent focusing behavior when called from another window +async fn windows_hello_authenticate_with_crypto( + challenge: &[u8; CHALLENGE_LENGTH], +) -> Result<[u8; XCHACHA20POLY1305_KEY_LENGTH]> { + debug!("[Windows Hello] Authenticating to sign challenge"); + + // Ugly hack: We need to focus the window via window focusing APIs until Microsoft releases a new API. + // This is unreliable, and if it does not work, the operation may fail + let stop_focusing = Arc::new(AtomicBool::new(false)); + let stop_focusing_clone = stop_focusing.clone(); + let _ = std::thread::spawn(move || loop { + if !stop_focusing_clone.load(std::sync::atomic::Ordering::Relaxed) { + focus_security_prompt(); + std::thread::sleep(std::time::Duration::from_millis(500)); + } else { + break; + } + }); + // Only stop focusing once this function exits. The focus MUST run both during the initial creation + // with RequestCreateAsync, and also with the subsequent use with RequestSignAsync. + let _guard = scopeguard::guard((), |_| { + stop_focusing.store(true, std::sync::atomic::Ordering::Relaxed); + }); + + // First create or replace the Bitwarden Biometrics signing key + let credential = { + let key_credential_creation_result = KeyCredentialManager::RequestCreateAsync( + CREDENTIAL_NAME, + KeyCredentialCreationOption::FailIfExists, + )? + .await?; + match key_credential_creation_result.Status()? { + KeyCredentialStatus::CredentialAlreadyExists => { + KeyCredentialManager::OpenAsync(CREDENTIAL_NAME)?.await? + } + KeyCredentialStatus::Success => key_credential_creation_result, + _ => return Err(anyhow!("Failed to create key credential")), + } + } + .Credential()?; + + let signature = { + let sign_operation = credential.RequestSignAsync( + &CryptographicBuffer::CreateFromByteArray(challenge.as_slice())?, + )?; + + // We need to drop the credential here to avoid holding it across an await point. + drop(credential); + sign_operation.await? + }; + + if signature.Status()? != KeyCredentialStatus::Success { + return Err(anyhow!("Failed to sign data")); + } + + let signature_buffer = signature.Result()?; + let signature_value = unsafe { as_mut_bytes(&signature_buffer)? }; + + // The signature is deterministic based on the challenge and keychain key. Thus, it can be hashed to a key. + // It is unclear what entropy this key provides. + let windows_hello_key = Sha256::digest(signature_value).into(); + Ok(windows_hello_key) +} + +async fn set_keychain_entry(user_id: &str, entry: &WindowsHelloKeychainEntry) -> Result<()> { + password::set_password( + KEYCHAIN_SERVICE_NAME, + user_id, + &serde_json::to_string(entry)?, + ) + .await +} + +async fn get_keychain_entry(user_id: &str) -> Result { + serde_json::from_str(&password::get_password(KEYCHAIN_SERVICE_NAME, user_id).await?) + .map_err(|e| anyhow!(e)) +} + +async fn delete_keychain_entry(user_id: &str) -> Result<()> { + password::delete_password(KEYCHAIN_SERVICE_NAME, user_id) + .await + .or_else(|e| { + if e.to_string() == PASSWORD_NOT_FOUND { + debug!( + "[Windows Hello] No keychain entry found for user {}, nothing to delete", + user_id + ); + Ok(()) + } else { + Err(e) + } + }) +} + +async fn has_keychain_entry(user_id: &str) -> Result { + password::get_password(KEYCHAIN_SERVICE_NAME, user_id) + .await + .map(|entry| !entry.is_empty()) + .or_else(|e| { + if e.to_string() == PASSWORD_NOT_FOUND { + Ok(false) + } else { + warn!( + "[Windows Hello] Error checking keychain entry for user {}: {}", + user_id, e + ); + Err(e) + } + }) +} + +/// Encrypt data with XChaCha20Poly1305 +fn encrypt_data( + key: &[u8; XCHACHA20POLY1305_KEY_LENGTH], + plaintext: &[u8], +) -> Result<(Vec, [u8; XCHACHA20POLY1305_NONCE_LENGTH])> { + let cipher = XChaCha20Poly1305::new(key.into()); + let mut nonce = [0u8; XCHACHA20POLY1305_NONCE_LENGTH]; + rand::fill(&mut nonce); + let ciphertext = cipher + .encrypt(XNonce::from_slice(&nonce), plaintext) + .map_err(|e| anyhow!(e))?; + Ok((ciphertext, nonce)) +} + +/// Decrypt data with XChaCha20Poly1305 +fn decrypt_data( + key: &[u8; XCHACHA20POLY1305_KEY_LENGTH], + ciphertext: &[u8], + nonce: &[u8; XCHACHA20POLY1305_NONCE_LENGTH], +) -> Result> { + let cipher = XChaCha20Poly1305::new(key.into()); + let plaintext = cipher + .decrypt(XNonce::from_slice(nonce), ciphertext) + .map_err(|e| anyhow!(e))?; + Ok(plaintext) +} + +unsafe fn as_mut_bytes(buffer: &IBuffer) -> Result<&mut [u8]> { + let interop = buffer.cast::()?; + + unsafe { + let data = interop.Buffer()?; + Ok(std::slice::from_raw_parts_mut( + data, + buffer.Length()? as usize, + )) + } +} + +#[cfg(test)] +mod tests { + use crate::biometric_v2::{ + biometric_v2::{ + decrypt_data, encrypt_data, has_keychain_entry, windows_hello_authenticate, + windows_hello_authenticate_with_crypto, CHALLENGE_LENGTH, XCHACHA20POLY1305_KEY_LENGTH, + }, + BiometricLockSystem, BiometricTrait, + }; + + #[test] + fn test_encrypt_decrypt() { + let key = [0u8; 32]; + let plaintext = b"Test data"; + let (ciphertext, nonce) = encrypt_data(&key, plaintext).unwrap(); + let decrypted = decrypt_data(&key, &ciphertext, &nonce).unwrap(); + assert_eq!(plaintext.to_vec(), decrypted); + } + + #[tokio::test] + async fn test_has_keychain_entry_no_entry() { + let user_id = "test_user"; + let has_entry = has_keychain_entry(user_id).await.unwrap(); + assert!(!has_entry); + } + + // Note: These tests are ignored because they require manual intervention to run + + #[tokio::test] + #[ignore] + async fn test_windows_hello_authenticate_with_crypto_manual() { + let challenge = [0u8; CHALLENGE_LENGTH]; + let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge) + .await + .unwrap(); + println!( + "Windows hello key {:?} for challenge {:?}", + windows_hello_key, challenge + ); + } + + #[tokio::test] + #[ignore] + async fn test_windows_hello_authenticate() { + let authenticated = + windows_hello_authenticate("Test Windows Hello authentication".to_string()) + .await + .unwrap(); + println!("Windows Hello authentication result: {:?}", authenticated); + } + + #[tokio::test] + #[ignore] + async fn test_double_unenroll() { + let user_id = "test_user"; + let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH]; + rand::fill(&mut key); + + let windows_hello_lock_system = BiometricLockSystem::new(); + + println!("Enrolling user"); + windows_hello_lock_system + .enroll_persistent(user_id, &key) + .await + .unwrap(); + assert!(windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + + println!("Unlocking user"); + let key_after_unlock = windows_hello_lock_system + .unlock(user_id, Vec::new()) + .await + .unwrap(); + assert_eq!(key_after_unlock, key); + + println!("Unenrolling user"); + windows_hello_lock_system.unenroll(user_id).await.unwrap(); + assert!(!windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + + println!("Unenrolling user again"); + + // This throws PASSWORD_NOT_FOUND but our code should handle that and not throw. + windows_hello_lock_system.unenroll(user_id).await.unwrap(); + assert!(!windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + } + + #[tokio::test] + #[ignore] + async fn test_enroll_unlock_unenroll() { + let user_id = "test_user"; + let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH]; + rand::fill(&mut key); + + let windows_hello_lock_system = BiometricLockSystem::new(); + + println!("Enrolling user"); + windows_hello_lock_system + .enroll_persistent(user_id, &key) + .await + .unwrap(); + assert!(windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + + println!("Unlocking user"); + let key_after_unlock = windows_hello_lock_system + .unlock(user_id, Vec::new()) + .await + .unwrap(); + assert_eq!(key_after_unlock, key); + + println!("Unenrolling user"); + windows_hello_lock_system.unenroll(user_id).await.unwrap(); + assert!(!windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + } +} diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs b/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs new file mode 100644 index 0000000000..f3ffb6e4eb --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs @@ -0,0 +1,100 @@ +use windows::{ + core::s, + Win32::{ + Foundation::HWND, + System::Threading::{AttachThreadInput, GetCurrentThreadId}, + UI::{ + Input::KeyboardAndMouse::{EnableWindow, SetActiveWindow, SetCapture, SetFocus}, + WindowsAndMessaging::{ + BringWindowToTop, FindWindowA, GetForegroundWindow, GetWindowThreadProcessId, + SetForegroundWindow, SwitchToThisWindow, SystemParametersInfoW, SPIF_SENDCHANGE, + SPIF_UPDATEINIFILE, SPI_GETFOREGROUNDLOCKTIMEOUT, SPI_SETFOREGROUNDLOCKTIMEOUT, + }, + }, + }, +}; + +pub(crate) struct HwndHolder(pub(crate) HWND); +unsafe impl Send for HwndHolder {} + +pub(crate) fn get_active_window() -> Option { + unsafe { Some(HwndHolder(GetForegroundWindow())) } +} + +/// Searches for a window that looks like a security prompt and set it as focused. +/// Only works when the process has permission to foreground, either by being in foreground +/// Or by being given foreground permission https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks +pub fn focus_security_prompt() { + let hwnd_result = unsafe { FindWindowA(s!("Credential Dialog Xaml Host"), None) }; + if let Ok(hwnd) = hwnd_result { + set_focus(hwnd); + } +} + +/// Sets focus to a window using a few unstable methods +fn set_focus(hwnd: HWND) { + unsafe { + // Windows REALLY does not like apps stealing focus, even if it is for fixing Windows-Hello bugs. + // The windows hello signing prompt NEEDS to be focused instantly, or it will error, but it does + // not focus itself. + + // This function implements forced focusing of windows using a few hacks. + // The conditions to successfully foreground a window are: + // All of the following conditions are true: + // The calling process belongs to a desktop application, not a UWP app or a Windows Store app designed for Windows 8 or 8.1. + // The foreground process has not disabled calls to SetForegroundWindow by a previous call to the LockSetForegroundWindow function. + // The foreground lock time-out has expired (see SPI_GETFOREGROUNDLOCKTIMEOUT in SystemParametersInfo). + // No menus are active. + // Additionally, at least one of the following conditions is true: + // The calling process is the foreground process. + // The calling process was started by the foreground process. + // There is currently no foreground window, and thus no foreground process. + // The calling process received the last input event. + // Either the foreground process or the calling process is being debugged. + + // Update the foreground lock timeout temporarily + let mut old_timeout = 0; + let _ = SystemParametersInfoW( + SPI_GETFOREGROUNDLOCKTIMEOUT, + 0, + Some(&mut old_timeout as *mut _ as *mut std::ffi::c_void), + windows::Win32::UI::WindowsAndMessaging::SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0), + ); + let _ = SystemParametersInfoW( + SPI_SETFOREGROUNDLOCKTIMEOUT, + 0, + None, + SPIF_UPDATEINIFILE | SPIF_SENDCHANGE, + ); + let _scopeguard = scopeguard::guard((), |_| { + let _ = SystemParametersInfoW( + SPI_SETFOREGROUNDLOCKTIMEOUT, + old_timeout, + None, + SPIF_UPDATEINIFILE | SPIF_SENDCHANGE, + ); + }); + + // Attach to the foreground thread once attached, we can foreground, even if in the background + let dw_current_thread = GetCurrentThreadId(); + let dw_fg_thread = GetWindowThreadProcessId(GetForegroundWindow(), None); + + let _ = AttachThreadInput(dw_current_thread, dw_fg_thread, true); + let _ = SetForegroundWindow(hwnd); + SetCapture(hwnd); + let _ = SetFocus(Some(hwnd)); + let _ = SetActiveWindow(hwnd); + let _ = EnableWindow(hwnd, true); + let _ = BringWindowToTop(hwnd); + SwitchToThisWindow(hwnd, true); + let _ = AttachThreadInput(dw_current_thread, dw_fg_thread, false); + } +} + +/// When restoring focus to the application window, we need a less aggressive method so the electron window doesn't get frozen. +pub(crate) fn restore_focus(hwnd: HWND) { + unsafe { + let _ = SetForegroundWindow(hwnd); + let _ = SetFocus(Some(hwnd)); + } +} diff --git a/apps/desktop/desktop_native/core/src/lib.rs b/apps/desktop/desktop_native/core/src/lib.rs index a72ec04e9c..668badb95e 100644 --- a/apps/desktop/desktop_native/core/src/lib.rs +++ b/apps/desktop/desktop_native/core/src/lib.rs @@ -1,13 +1,15 @@ pub mod autofill; pub mod autostart; pub mod biometric; +pub mod biometric_v2; pub mod clipboard; -pub mod crypto; +pub(crate) mod crypto; pub mod error; pub mod ipc; pub mod password; pub mod powermonitor; pub mod process_isolation; +pub(crate) mod secure_memory; pub mod ssh_agent; use zeroizing_alloc::ZeroAlloc; diff --git a/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs new file mode 100644 index 0000000000..ca9b6081d6 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs @@ -0,0 +1,134 @@ +use std::collections::HashMap; + +use windows::Win32::Security::Cryptography::{ + CryptProtectMemory, CryptUnprotectMemory, CRYPTPROTECTMEMORY_BLOCK_SIZE, + CRYPTPROTECTMEMORY_SAME_PROCESS, +}; + +use crate::secure_memory::SecureMemoryStore; + +/// https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata +/// The DPAPI store encrypts data using the Windows Data Protection API (DPAPI). The key is bound +/// to the current process, and cannot be decrypted by other user-mode processes. +/// +/// Note: Admin processes can still decrypt this memory: +/// https://blog.slowerzs.net/posts/cryptdecryptmemory/ +pub(crate) struct DpapiSecretKVStore { + map: HashMap>, +} + +impl DpapiSecretKVStore { + pub(crate) fn new() -> Self { + DpapiSecretKVStore { + map: HashMap::new(), + } + } +} + +impl SecureMemoryStore for DpapiSecretKVStore { + fn put(&mut self, key: String, value: &[u8]) { + let length_header_len = std::mem::size_of::(); + + // The allocated data has to be a multiple of CRYPTPROTECTMEMORY_BLOCK_SIZE, so we pad it and write the length in front + // We are storing LENGTH|DATA|00..00, where LENGTH is the length of DATA, the total length is a multiple + // of CRYPTPROTECTMEMORY_BLOCK_SIZE, and the padding is filled with zeros. + + let data_len = value.len(); + let len_with_header = data_len + length_header_len; + let padded_length = len_with_header + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize + - (len_with_header % CRYPTPROTECTMEMORY_BLOCK_SIZE as usize); + let mut padded_data = vec![0u8; padded_length]; + padded_data[..length_header_len].copy_from_slice(&data_len.to_le_bytes()); + padded_data[length_header_len..][..data_len].copy_from_slice(value); + + // Protect the memory using DPAPI + unsafe { + CryptProtectMemory( + padded_data.as_mut_ptr() as *mut core::ffi::c_void, + padded_length as u32, + CRYPTPROTECTMEMORY_SAME_PROCESS, + ) + } + .expect("crypt_protect_memory should work"); + + self.map.insert(key, padded_data); + } + + fn get(&self, key: &str) -> Option> { + self.map.get(key).map(|data| { + // A copy is created, that is then mutated by the DPAPI unprotect function. + let mut data = data.clone(); + unsafe { + CryptUnprotectMemory( + data.as_mut_ptr() as *mut core::ffi::c_void, + data.len() as u32, + CRYPTPROTECTMEMORY_SAME_PROCESS, + ) + } + .expect("crypt_unprotect_memory should work"); + + // Unpad the data to retrieve the original value + let length_header_size = std::mem::size_of::(); + let length_bytes = &data[..length_header_size]; + let data_length = usize::from_le_bytes( + length_bytes + .try_into() + .expect("length header should be usize"), + ); + + data[length_header_size..length_header_size + data_length].to_vec() + }) + } + + fn has(&self, key: &str) -> bool { + self.map.contains_key(key) + } + + fn remove(&mut self, key: &str) { + self.map.remove(key); + } + + fn clear(&mut self) { + self.map.clear(); + } +} + +impl Drop for DpapiSecretKVStore { + fn drop(&mut self) { + self.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dpapi_secret_kv_store_various_sizes() { + let mut store = DpapiSecretKVStore::new(); + for size in 0..=2048 { + let key = format!("test_key_{}", size); + let value: Vec = (0..size).map(|i| (i % 256) as u8).collect(); + store.put(key.clone(), &value); + assert!(store.has(&key), "Store should have key for size {}", size); + assert_eq!( + store.get(&key), + Some(value), + "Value mismatch for size {}", + size + ); + } + } + + #[test] + fn test_dpapi_crud() { + let mut store = DpapiSecretKVStore::new(); + let key = "test_key".to_string(); + let value = vec![1, 2, 3, 4, 5]; + store.put(key.clone(), &value); + assert!(store.has(&key)); + assert_eq!(store.get(&key), Some(value)); + store.remove(&key); + assert!(!store.has(&key)); + } +} diff --git a/apps/desktop/desktop_native/core/src/secure_memory/mod.rs b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs new file mode 100644 index 0000000000..0cb604e03f --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs @@ -0,0 +1,22 @@ +#[cfg(target_os = "windows")] +pub(crate) mod dpapi; + +/// The secure memory store provides an ephemeral key-value store for sensitive data. +/// Data stored in this store is prevented from being swapped to disk and zeroed out. Additionally, +/// platform-specific protections are applied to prevent memory dumps or debugger access from +/// reading the stored values. +#[allow(unused)] +pub(crate) trait SecureMemoryStore { + /// Stores a copy of the provided value in secure memory. + fn put(&mut self, key: String, value: &[u8]); + /// Retrieves a copy of the value associated with the given key from secure memory. + /// This copy does not have additional memory protections applied, and should be zeroed when no + /// longer needed. + fn get(&self, key: &str) -> Option>; + /// Checks if a value is stored under the given key. + fn has(&self, key: &str) -> bool; + /// Removes the value associated with the given key from secure memory. + fn remove(&mut self, key: &str); + /// Clears all values stored in secure memory. + fn clear(&mut self); +} diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index c822017c1a..59751cd324 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -58,6 +58,18 @@ export declare namespace biometrics { ivB64: string } } +export declare namespace biometrics_v2 { + export function initBiometricSystem(): BiometricLockSystem + export function authenticate(biometricLockSystem: BiometricLockSystem, hwnd: Buffer, message: string): Promise + export function authenticateAvailable(biometricLockSystem: BiometricLockSystem): Promise + export function enrollPersistent(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise + export function provideKey(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise + export function unlock(biometricLockSystem: BiometricLockSystem, userId: string, hwnd: Buffer): Promise + export function unlockAvailable(biometricLockSystem: BiometricLockSystem, userId: string): Promise + export function hasPersistent(biometricLockSystem: BiometricLockSystem, userId: string): Promise + export function unenroll(biometricLockSystem: BiometricLockSystem, userId: string): Promise + export class BiometricLockSystem { } +} export declare namespace clipboards { export function read(): Promise export function write(text: string, password: boolean): Promise diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 1f725386c8..a193e44d6d 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -149,6 +149,123 @@ pub mod biometrics { } } +#[napi] +pub mod biometrics_v2 { + use desktop_core::biometric_v2::BiometricTrait; + + #[napi] + pub struct BiometricLockSystem { + inner: desktop_core::biometric_v2::BiometricLockSystem, + } + + #[napi] + pub fn init_biometric_system() -> napi::Result { + Ok(BiometricLockSystem { + inner: desktop_core::biometric_v2::BiometricLockSystem::new(), + }) + } + + #[napi] + pub async fn authenticate( + biometric_lock_system: &BiometricLockSystem, + hwnd: napi::bindgen_prelude::Buffer, + message: String, + ) -> napi::Result { + biometric_lock_system + .inner + .authenticate(hwnd.into(), message) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn authenticate_available( + biometric_lock_system: &BiometricLockSystem, + ) -> napi::Result { + biometric_lock_system + .inner + .authenticate_available() + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn enroll_persistent( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + key: napi::bindgen_prelude::Buffer, + ) -> napi::Result<()> { + biometric_lock_system + .inner + .enroll_persistent(&user_id, &key) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn provide_key( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + key: napi::bindgen_prelude::Buffer, + ) -> napi::Result<()> { + biometric_lock_system + .inner + .provide_key(&user_id, &key) + .await; + Ok(()) + } + + #[napi] + pub async fn unlock( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + hwnd: napi::bindgen_prelude::Buffer, + ) -> napi::Result { + biometric_lock_system + .inner + .unlock(&user_id, hwnd.into()) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + .map(|v| v.into()) + } + + #[napi] + pub async fn unlock_available( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + ) -> napi::Result { + biometric_lock_system + .inner + .unlock_available(&user_id) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn has_persistent( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + ) -> napi::Result { + biometric_lock_system + .inner + .has_persistent(&user_id) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn unenroll( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + ) -> napi::Result<()> { + biometric_lock_system + .inner + .unenroll(&user_id) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } +} + #[napi] pub mod clipboards { #[allow(clippy::unused_async)] // FIXME: Remove unused async! diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index dfddff034e..e120db339d 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -81,6 +81,31 @@ "additionalTouchIdSettings" | i18n }} +
+
+ +
+
{ const desktopAutotypeService = mock(); const billingAccountProfileStateService = mock(); const configService = mock(); + const userVerificationService = mock(); + + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)); beforeEach(async () => { jest.clearAllMocks(); @@ -92,6 +96,7 @@ describe("SettingsComponent", () => { }; i18nService.supportedTranslationLocales = []; + i18nService.t.mockImplementation((key: string) => key); await TestBed.configureTestingModule({ imports: [], @@ -124,7 +129,7 @@ describe("SettingsComponent", () => { { provide: PolicyService, useValue: policyService }, { provide: StateService, useValue: mock() }, { provide: ThemeStateService, useValue: themeStateService }, - { provide: UserVerificationService, useValue: mock() }, + { provide: UserVerificationService, useValue: userVerificationService }, { provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService }, { provide: ValidationService, useValue: validationService }, { provide: MessagingService, useValue: messagingService }, @@ -153,6 +158,7 @@ describe("SettingsComponent", () => { component = fixture.componentInstance; fixture.detectChanges(); + desktopBiometricsService.hasPersistentKey.mockResolvedValue(false); vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue( of(VaultTimeoutStringType.OnLocked), ); @@ -296,43 +302,81 @@ describe("SettingsComponent", () => { describe("windows desktop", () => { beforeEach(() => { platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + desktopBiometricsService.isWindowsV2BiometricsEnabled.mockResolvedValue(true); // Recreate component to apply the correct device fixture = TestBed.createComponent(SettingsComponent); component = fixture.componentInstance; }); - it("require password or pin on app start not visible when RemoveUnlockWithPin policy is disabled and pin set and windows desktop", async () => { - const policy = new Policy(); - policy.type = PolicyType.RemoveUnlockWithPin; - policy.enabled = false; - policyService.policiesByType$.mockReturnValue(of([policy])); - pinServiceAbstraction.isPinSet.mockResolvedValue(true); + test.each([true, false])( + `correct message display for require MP/PIN on app restart when pin is set, windows desktop, and policy is %s`, + async (policyEnabled) => { + const policy = new Policy(); + policy.type = PolicyType.RemoveUnlockWithPin; + policy.enabled = policyEnabled; + policyService.policiesByType$.mockReturnValue(of([policy])); + platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + pinServiceAbstraction.isPinSet.mockResolvedValue(true); - await component.ngOnInit(); - fixture.detectChanges(); + await component.ngOnInit(); + fixture.detectChanges(); - const requirePasswordOnStartLabelElement = fixture.debugElement.query( - By.css("label[for='requirePasswordOnStart']"), - ); - expect(requirePasswordOnStartLabelElement).toBeNull(); + const textNodes = checkRequireMasterPasswordOnAppRestartElement(fixture); + + if (policyEnabled) { + expect(textNodes).toContain("requireMasterPasswordOnAppRestart"); + } else { + expect(textNodes).toContain("requireMasterPasswordOrPinOnAppRestart"); + } + }, + ); + + describe("users without a master password", () => { + beforeEach(() => { + userVerificationService.hasMasterPassword.mockResolvedValue(false); + }); + + it("displays require MP/PIN on app restart checkbox when pin is set", async () => { + pinServiceAbstraction.isPinSet.mockResolvedValue(true); + + await component.ngOnInit(); + fixture.detectChanges(); + + checkRequireMasterPasswordOnAppRestartElement(fixture); + }); + + it("does not display require MP/PIN on app restart checkbox when pin is not set", async () => { + pinServiceAbstraction.isPinSet.mockResolvedValue(false); + + await component.ngOnInit(); + fixture.detectChanges(); + + const requireMasterPasswordOnAppRestartLabelElement = fixture.debugElement.query( + By.css("label[for='requireMasterPasswordOnAppRestart']"), + ); + expect(requireMasterPasswordOnAppRestartLabelElement).toBeNull(); + }); }); - it("require password on app start not visible when RemoveUnlockWithPin policy is enabled and pin set and windows desktop", async () => { - const policy = new Policy(); - policy.type = PolicyType.RemoveUnlockWithPin; - policy.enabled = true; - policyService.policiesByType$.mockReturnValue(of([policy])); - pinServiceAbstraction.isPinSet.mockResolvedValue(true); - - await component.ngOnInit(); - fixture.detectChanges(); - - const requirePasswordOnStartLabelElement = fixture.debugElement.query( - By.css("label[for='requirePasswordOnStart']"), + function checkRequireMasterPasswordOnAppRestartElement( + fixture: ComponentFixture, + ) { + const requireMasterPasswordOnAppRestartLabelElement = fixture.debugElement.query( + By.css("label[for='requireMasterPasswordOnAppRestart']"), ); - expect(requirePasswordOnStartLabelElement).toBeNull(); - }); + expect(requireMasterPasswordOnAppRestartLabelElement).not.toBeNull(); + expect(requireMasterPasswordOnAppRestartLabelElement.children).toHaveLength(1); + expect(requireMasterPasswordOnAppRestartLabelElement.children[0].name).toBe("input"); + expect(requireMasterPasswordOnAppRestartLabelElement.children[0].attributes).toMatchObject({ + id: "requireMasterPasswordOnAppRestart", + type: "checkbox", + }); + const textNodes = requireMasterPasswordOnAppRestartLabelElement.childNodes + .filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE) + .map((node) => node.nativeNode.wholeText?.trim()); + return textNodes; + } }); }); @@ -362,7 +406,7 @@ describe("SettingsComponent", () => { await component.updatePinHandler(true); expect(component.form.controls.pin.value).toBe(false); - expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled(); + expect(pinServiceAbstraction.unsetPin).not.toHaveBeenCalled(); expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); }); @@ -378,7 +422,7 @@ describe("SettingsComponent", () => { await component.updatePinHandler(true); expect(component.form.controls.pin.value).toBe(dialogResult); - expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled(); + expect(pinServiceAbstraction.unsetPin).not.toHaveBeenCalled(); expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); }, ); @@ -390,9 +434,147 @@ describe("SettingsComponent", () => { await component.updatePinHandler(false); expect(component.form.controls.pin.value).toBe(false); - expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled(); + expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled(); expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); }); + + describe("when windows biometric v2 feature flag is enabled", () => { + beforeEach(() => { + keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey)); + }); + + test.each([false, true])( + "enrolls persistent biometric if needed, enrolled is %s", + async (enrolled) => { + desktopBiometricsService.hasPersistentKey.mockResolvedValue(enrolled); + + await component.ngOnInit(); + component.isWindowsV2BiometricsEnabled = true; + component.isWindows = true; + component.form.value.requireMasterPasswordOnAppRestart = true; + component.userHasMasterPassword = false; + component.supportsBiometric = true; + component.form.value.biometric = true; + + await component.updatePinHandler(false); + + expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false); + expect(component.form.controls.pin.value).toBe(false); + expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled(); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + + if (enrolled) { + expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled(); + } else { + expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith( + mockUserId, + mockUserKey, + ); + } + }, + ); + + test.each([ + { + userHasMasterPassword: true, + supportsBiometric: false, + biometric: false, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: true, + supportsBiometric: false, + biometric: false, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: true, + supportsBiometric: false, + biometric: true, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: true, + supportsBiometric: false, + biometric: true, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: true, + supportsBiometric: true, + biometric: false, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: true, + supportsBiometric: true, + biometric: false, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: false, + supportsBiometric: false, + biometric: false, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: false, + supportsBiometric: false, + biometric: false, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: false, + supportsBiometric: false, + biometric: true, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: false, + supportsBiometric: false, + biometric: true, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: false, + supportsBiometric: true, + biometric: false, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: false, + supportsBiometric: true, + biometric: false, + requireMasterPasswordOnAppRestart: true, + }, + ])( + "does not enroll persistent biometric when conditions are not met: userHasMasterPassword=$userHasMasterPassword, supportsBiometric=$supportsBiometric, biometric=$biometric, requireMasterPasswordOnAppRestart=$requireMasterPasswordOnAppRestart", + async ({ + userHasMasterPassword, + supportsBiometric, + biometric, + requireMasterPasswordOnAppRestart, + }) => { + desktopBiometricsService.hasPersistentKey.mockResolvedValue(false); + + await component.ngOnInit(); + component.isWindowsV2BiometricsEnabled = true; + component.isWindows = true; + component.form.value.requireMasterPasswordOnAppRestart = + requireMasterPasswordOnAppRestart; + component.userHasMasterPassword = userHasMasterPassword; + component.supportsBiometric = supportsBiometric; + component.form.value.biometric = biometric; + + await component.updatePinHandler(false); + + expect(component.form.controls.pin.value).toBe(false); + expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled(); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled(); + }, + ); + }); }); }); @@ -474,22 +656,92 @@ describe("SettingsComponent", () => { expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); }); - it("handles windows case", async () => { - desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available); - desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue( - BiometricsStatus.Available, - ); + describe("windows test cases", () => { + beforeEach(() => { + platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + component.isWindows = true; + component.isLinux = false; - component.isWindows = true; - component.isLinux = false; - await component.updateBiometricHandler(true); + desktopBiometricsService.getBiometricsStatus.mockResolvedValue( + BiometricsStatus.Available, + ); + desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue( + BiometricsStatus.Available, + ); + }); - expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); - expect(component.form.controls.autoPromptBiometrics.value).toBe(false); - expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false); - expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId); - expect(component.form.controls.biometric.value).toBe(true); - expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + it("handles windows case", async () => { + await component.updateBiometricHandler(true); + + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(component.form.controls.autoPromptBiometrics.value).toBe(false); + expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false); + expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId); + expect(component.form.controls.biometric.value).toBe(true); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + }); + + describe("when windows v2 biometrics is enabled", () => { + beforeEach(() => { + component.isWindowsV2BiometricsEnabled = true; + + keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey)); + }); + + it("when the user doesn't have a master password or a PIN set, allows biometric unlock on app restart", async () => { + component.userHasMasterPassword = false; + component.userHasPinSet = false; + desktopBiometricsService.hasPersistentKey.mockResolvedValue(false); + + await component.updateBiometricHandler(true); + + expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId); + expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith( + mockUserId, + mockUserKey, + ); + expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false); + + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(component.form.controls.autoPromptBiometrics.value).toBe(false); + expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false); + expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId); + expect(component.form.controls.biometric.value).toBe(true); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + }); + + test.each([ + [true, true], + [true, false], + [false, true], + ])( + "when the userHasMasterPassword is %s and userHasPinSet is %s, require master password/PIN on app restart is the default setting", + async (userHasMasterPassword, userHasPinSet) => { + component.userHasMasterPassword = userHasMasterPassword; + component.userHasPinSet = userHasPinSet; + + await component.updateBiometricHandler(true); + + expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled(); + expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(true); + expect(desktopBiometricsService.deleteBiometricUnlockKeyForUser).toHaveBeenCalledWith( + mockUserId, + ); + expect( + desktopBiometricsService.setBiometricProtectedUnlockKeyForUser, + ).toHaveBeenCalledWith(mockUserId, mockUserKey); + + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(component.form.controls.autoPromptBiometrics.value).toBe(false); + expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false); + expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId); + expect(component.form.controls.biometric.value).toBe(true); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + }, + ); + }); }); it("handles linux case", async () => { @@ -553,6 +805,57 @@ describe("SettingsComponent", () => { }); }); + describe("updateRequireMasterPasswordOnAppRestartHandler", () => { + beforeEach(() => { + jest.clearAllMocks(); + + keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey)); + }); + + test.each([true, false])(`handles thrown errors when updated to %s`, async (update) => { + const error = new Error("Test error"); + jest.spyOn(component, "updateRequireMasterPasswordOnAppRestart").mockRejectedValue(error); + + await component.ngOnInit(); + await component.updateRequireMasterPasswordOnAppRestartHandler(update, mockUserId); + + expect(logService.error).toHaveBeenCalled(); + expect(validationService.showError).toHaveBeenCalledWith(error); + }); + + describe("when updating to true", () => { + it("calls the biometrics service to clear and reset biometric key", async () => { + await component.ngOnInit(); + await component.updateRequireMasterPasswordOnAppRestartHandler(true, mockUserId); + + expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId); + expect(desktopBiometricsService.deleteBiometricUnlockKeyForUser).toHaveBeenCalledWith( + mockUserId, + ); + expect(desktopBiometricsService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith( + mockUserId, + mockUserKey, + ); + }); + }); + + describe("when updating to false", () => { + it("doesn't enroll persistent biometric if already enrolled", async () => { + biometricStateService.hasPersistentKey.mockResolvedValue(false); + + await component.ngOnInit(); + await component.updateRequireMasterPasswordOnAppRestartHandler(false, mockUserId); + + expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId); + expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith( + mockUserId, + mockUserKey, + ); + expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false); + }); + }); + }); + describe("saveVaultTimeout", () => { const DEFAULT_VAULT_TIMEOUT: VaultTimeout = 123; const DEFAULT_VAULT_TIMEOUT_ACTION = VaultTimeoutAction.Lock; diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 53b2cad437..7666e9bef1 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -142,6 +142,7 @@ export class SettingsComponent implements OnInit, OnDestroy { userHasPinSet: boolean; pinEnabled$: Observable = of(true); + isWindowsV2BiometricsEnabled: boolean = false; form = this.formBuilder.group({ // Security @@ -149,6 +150,7 @@ export class SettingsComponent implements OnInit, OnDestroy { vaultTimeoutAction: [VaultTimeoutAction.Lock], pin: [null as boolean | null], biometric: false, + requireMasterPasswordOnAppRestart: true, autoPromptBiometrics: false, // Account Preferences clearClipboard: [null], @@ -281,6 +283,8 @@ export class SettingsComponent implements OnInit, OnDestroy { } async ngOnInit() { + this.isWindowsV2BiometricsEnabled = await this.biometricsService.isWindowsV2BiometricsEnabled(); + this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions(); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); @@ -372,6 +376,9 @@ export class SettingsComponent implements OnInit, OnDestroy { ), pin: this.userHasPinSet, biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(), + requireMasterPasswordOnAppRestart: !(await this.biometricsService.hasPersistentKey( + activeAccount.id, + )), autoPromptBiometrics: await firstValueFrom(this.biometricStateService.promptAutomatically$), clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$), minimizeOnCopyToClipboard: await firstValueFrom(this.desktopSettingsService.minimizeOnCopy$), @@ -479,6 +486,15 @@ export class SettingsComponent implements OnInit, OnDestroy { ) .subscribe(); + this.form.controls.requireMasterPasswordOnAppRestart.valueChanges + .pipe( + concatMap(async (value) => { + await this.updateRequireMasterPasswordOnAppRestartHandler(value, activeAccount.id); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + this.form.controls.enableBrowserIntegration.valueChanges .pipe(takeUntil(this.destroy$)) .subscribe((enabled) => { @@ -588,6 +604,19 @@ export class SettingsComponent implements OnInit, OnDestroy { this.form.controls.pin.setValue(this.userHasPinSet, { emitEvent: false }); } else { const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + // On Windows if a user turned off PIN without having a MP and has biometrics + require MP/PIN on restart enabled. + if ( + this.isWindows && + this.isWindowsV2BiometricsEnabled && + this.supportsBiometric && + this.form.value.requireMasterPasswordOnAppRestart && + this.form.value.biometric && + !this.userHasMasterPassword + ) { + // Allow biometric unlock on app restart so the user doesn't get into a bad state. + await this.enrollPersistentBiometricIfNeeded(userId); + } await this.pinService.unsetPin(userId); } } @@ -639,6 +668,16 @@ export class SettingsComponent implements OnInit, OnDestroy { // Recommended settings for Windows Hello this.form.controls.autoPromptBiometrics.setValue(false); await this.biometricStateService.setPromptAutomatically(false); + + if (this.isWindowsV2BiometricsEnabled) { + // If the user doesn't have a MP or PIN then they have to use biometrics on app restart. + if (!this.userHasMasterPassword && !this.userHasPinSet) { + // Allow biometric unlock on app restart so the user doesn't get into a bad state. + await this.enrollPersistentBiometricIfNeeded(activeUserId); + } else { + this.form.controls.requireMasterPasswordOnAppRestart.setValue(true); + } + } } else if (this.isLinux) { // Similar to Windows this.form.controls.autoPromptBiometrics.setValue(false); @@ -656,6 +695,37 @@ export class SettingsComponent implements OnInit, OnDestroy { } } + async updateRequireMasterPasswordOnAppRestartHandler(enabled: boolean, userId: UserId) { + try { + await this.updateRequireMasterPasswordOnAppRestart(enabled, userId); + } catch (error) { + this.logService.error("Error updating require master password on app restart: ", error); + this.validationService.showError(error); + } + } + + async updateRequireMasterPasswordOnAppRestart(enabled: boolean, userId: UserId) { + if (enabled) { + // Require master password or PIN on app restart + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + await this.biometricsService.deleteBiometricUnlockKeyForUser(userId); + await this.biometricsService.setBiometricProtectedUnlockKeyForUser(userId, userKey); + } else { + // Allow biometric unlock on app restart + await this.enrollPersistentBiometricIfNeeded(userId); + } + } + + private async enrollPersistentBiometricIfNeeded(userId: UserId): Promise { + if (!(await this.biometricsService.hasPersistentKey(userId))) { + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + await this.biometricsService.enrollPersistent(userId, userKey); + this.form.controls.requireMasterPasswordOnAppRestart.setValue(false, { + emitEvent: false, + }); + } + } + async updateAutoPromptBiometrics() { if (this.form.value.autoPromptBiometrics) { await this.biometricStateService.setPromptAutomatically(true); diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 79c93c1390..ae633bd4a6 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -28,6 +28,7 @@ import { DesktopAutotypeService } from "../../autofill/services/desktop-autotype import { SshAgentService } from "../../autofill/services/ssh-agent.service"; import { I18nRendererService } from "../../platform/services/i18n.renderer.service"; import { VersionService } from "../../platform/services/version.service"; +import { BiometricMessageHandlerService } from "../../services/biometric-message-handler.service"; import { NativeMessagingService } from "../../services/native-messaging.service"; @Injectable() @@ -53,6 +54,7 @@ export class InitService { private autofillService: DesktopAutofillService, private autotypeService: DesktopAutotypeService, private sdkLoadService: SdkLoadService, + private biometricMessageHandlerService: BiometricMessageHandlerService, private configService: ConfigService, @Inject(DOCUMENT) private document: Document, private readonly migrationRunner: MigrationRunner, @@ -95,6 +97,7 @@ export class InitService { const containerService = new ContainerService(this.keyService, this.encryptService); containerService.attachToGlobal(this.win); + await this.biometricMessageHandlerService.init(); await this.autofillService.init(); await this.autotypeService.init(); }; diff --git a/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts b/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts index 97e1d322a0..0fe3d7e95e 100644 --- a/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts @@ -13,4 +13,9 @@ export abstract class DesktopBiometricsService extends BiometricsService { ): Promise; abstract deleteBiometricUnlockKeyForUser(userId: UserId): Promise; abstract setupBiometrics(): Promise; + abstract enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise; + abstract hasPersistentKey(userId: UserId): Promise; + /* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */ + abstract enableWindowsV2Biometrics(): Promise; + abstract isWindowsV2BiometricsEnabled(): Promise; } diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts b/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts index d4ce01f53f..24bb5495da 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts @@ -51,6 +51,17 @@ export class MainBiometricsIPCListener { return await this.biometricService.setShouldAutopromptNow(message.data as boolean); case BiometricAction.GetShouldAutoprompt: return await this.biometricService.getShouldAutopromptNow(); + case BiometricAction.HasPersistentKey: + return await this.biometricService.hasPersistentKey(message.userId as UserId); + case BiometricAction.EnrollPersistent: + return await this.biometricService.enrollPersistent( + message.userId as UserId, + SymmetricCryptoKey.fromString(message.key as string), + ); + case BiometricAction.EnableWindowsV2: + return await this.biometricService.enableWindowsV2Biometrics(); + case BiometricAction.IsWindowsV2Enabled: + return await this.biometricService.isWindowsV2BiometricsEnabled(); default: return; } diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts index bc57a7e55f..be9e1f841e 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts @@ -7,6 +7,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { EncryptionType } from "@bitwarden/common/platform/enums"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; +import { newGuid } from "@bitwarden/guid"; import { BiometricsService, BiometricsStatus, @@ -16,6 +17,7 @@ import { import { WindowMain } from "../../main/window.main"; import { MainBiometricsService } from "./main-biometrics.service"; +import { WindowsBiometricsSystem } from "./native-v2"; import OsBiometricsServiceLinux from "./os-biometrics-linux.service"; import OsBiometricsServiceMac from "./os-biometrics-mac.service"; import OsBiometricsServiceWindows from "./os-biometrics-windows.service"; @@ -28,6 +30,13 @@ jest.mock("@bitwarden/desktop-napi", () => { }; }); +jest.mock("./native-v2", () => ({ + WindowsBiometricsSystem: jest.fn(), + biometrics_v2: { + initBiometricSystem: jest.fn(), + }, +})); + const unlockKey = new SymmetricCryptoKey(new Uint8Array(64)); describe("MainBiometricsService", function () { @@ -38,24 +47,6 @@ describe("MainBiometricsService", function () { const cryptoFunctionService = mock(); const encryptService = mock(); - it("Should call the platformspecific methods", async () => { - const sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - - const mockService = mock(); - (sut as any).osBiometricsService = mockService; - - await sut.authenticateBiometric(); - expect(mockService.authenticateBiometric).toBeCalled(); - }); - describe("Should create a platform specific service", function () { it("Should create a biometrics service specific for Windows", () => { const sut = new MainBiometricsService( @@ -207,46 +198,6 @@ describe("MainBiometricsService", function () { }); }); - describe("setupBiometrics", () => { - it("should call the platform specific setup method", async () => { - const sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - const osBiometricsService = mock(); - (sut as any).osBiometricsService = osBiometricsService; - - await sut.setupBiometrics(); - - expect(osBiometricsService.runSetup).toHaveBeenCalled(); - }); - }); - - describe("authenticateWithBiometrics", () => { - it("should call the platform specific authenticate method", async () => { - const sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - const osBiometricsService = mock(); - (sut as any).osBiometricsService = osBiometricsService; - - await sut.authenticateWithBiometrics(); - - expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled(); - }); - }); - describe("unlockWithBiometricsForUser", () => { let sut: MainBiometricsService; let osBiometricsService: MockProxy; @@ -288,55 +239,6 @@ describe("MainBiometricsService", function () { }); }); - describe("setBiometricProtectedUnlockKeyForUser", () => { - let sut: MainBiometricsService; - let osBiometricsService: MockProxy; - - beforeEach(() => { - sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - osBiometricsService = mock(); - (sut as any).osBiometricsService = osBiometricsService; - }); - - it("should call the platform specific setBiometricKey method", async () => { - const userId = "test" as UserId; - - await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey); - - expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey); - }); - }); - - describe("deleteBiometricUnlockKeyForUser", () => { - it("should call the platform specific deleteBiometricKey method", async () => { - const sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - const osBiometricsService = mock(); - (sut as any).osBiometricsService = osBiometricsService; - - const userId = "test" as UserId; - - await sut.deleteBiometricUnlockKeyForUser(userId); - - expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId); - }); - }); - describe("setShouldAutopromptNow", () => { let sut: MainBiometricsService; @@ -386,4 +288,138 @@ describe("MainBiometricsService", function () { expect(shouldAutoPrompt).toBe(true); }); }); + + describe("enableWindowsV2Biometrics", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("enables Windows V2 biometrics when platform is win32 and not already enabled", async () => { + const sut = new MainBiometricsService( + i18nService, + windowMain, + logService, + "win32", + biometricStateService, + encryptService, + cryptoFunctionService, + ); + + await sut.enableWindowsV2Biometrics(); + + expect(logService.info).toHaveBeenCalledWith( + "[BiometricsMain] Loading native biometrics module v2 for windows", + ); + expect(await sut.isWindowsV2BiometricsEnabled()).toBe(true); + const internalService = (sut as any).osBiometricsService; + expect(internalService).not.toBeNull(); + expect(internalService).toBeInstanceOf(WindowsBiometricsSystem); + }); + + it("should not enable Windows V2 biometrics when platform is not win32", async () => { + const sut = new MainBiometricsService( + i18nService, + windowMain, + logService, + "darwin", + biometricStateService, + encryptService, + cryptoFunctionService, + ); + + await sut.enableWindowsV2Biometrics(); + + expect(logService.info).not.toHaveBeenCalled(); + expect(await sut.isWindowsV2BiometricsEnabled()).toBe(false); + }); + + it("should not enable Windows V2 biometrics when already enabled", async () => { + const sut = new MainBiometricsService( + i18nService, + windowMain, + logService, + "win32", + biometricStateService, + encryptService, + cryptoFunctionService, + ); + + // Enable it first + await sut.enableWindowsV2Biometrics(); + + // Enable it again + await sut.enableWindowsV2Biometrics(); + + expect(logService.info).toHaveBeenCalledWith( + "[BiometricsMain] Loading native biometrics module v2 for windows", + ); + expect(logService.info).toHaveBeenCalledTimes(1); + expect(await sut.isWindowsV2BiometricsEnabled()).toBe(true); + const internalService = (sut as any).osBiometricsService; + expect(internalService).not.toBeNull(); + expect(internalService).toBeInstanceOf(WindowsBiometricsSystem); + }); + }); + + describe("pass through methods that call platform specific osBiometricsService methods", () => { + const userId = newGuid() as UserId; + let sut: MainBiometricsService; + let osBiometricsService: MockProxy; + + beforeEach(() => { + sut = new MainBiometricsService( + i18nService, + windowMain, + logService, + process.platform, + biometricStateService, + encryptService, + cryptoFunctionService, + ); + osBiometricsService = mock(); + (sut as any).osBiometricsService = osBiometricsService; + }); + + it("calls the platform specific setBiometricKey method", async () => { + await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey); + + expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey); + }); + + it("calls the platform specific enrollPersistent method", async () => { + await sut.enrollPersistent(userId, unlockKey); + + expect(osBiometricsService.enrollPersistent).toHaveBeenCalledWith(userId, unlockKey); + }); + + it("calls the platform specific hasPersistentKey method", async () => { + await sut.hasPersistentKey(userId); + + expect(osBiometricsService.hasPersistentKey).toHaveBeenCalledWith(userId); + }); + + it("calls the platform specific deleteBiometricUnlockKeyForUser method", async () => { + await sut.deleteBiometricUnlockKeyForUser(userId); + + expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId); + }); + + it("calls the platform specific authenticateWithBiometrics method", async () => { + await sut.authenticateWithBiometrics(); + + expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled(); + }); + + it("calls the platform specific authenticateBiometric method", async () => { + await sut.authenticateBiometric(); + + expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled(); + }); + + it("calls the platform specific setupBiometrics method", async () => { + await sut.setupBiometrics(); + + expect(osBiometricsService.runSetup).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts index 1de8e3cd12..d1aff17646 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts @@ -10,17 +10,19 @@ import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-manageme import { WindowMain } from "../../main/window.main"; import { DesktopBiometricsService } from "./desktop.biometrics.service"; +import { WindowsBiometricsSystem } from "./native-v2"; import { OsBiometricService } from "./os-biometrics.service"; export class MainBiometricsService extends DesktopBiometricsService { private osBiometricsService: OsBiometricService; private shouldAutoPrompt = true; + private windowsV2BiometricsEnabled = false; constructor( private i18nService: I18nService, private windowMain: WindowMain, private logService: LogService, - platform: NodeJS.Platform, + private platform: NodeJS.Platform, private biometricStateService: BiometricStateService, private encryptService: EncryptService, private cryptoFunctionService: CryptoFunctionService, @@ -144,4 +146,28 @@ export class MainBiometricsService extends DesktopBiometricsService { async canEnableBiometricUnlock(): Promise { return true; } + + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { + return await this.osBiometricsService.enrollPersistent(userId, key); + } + + async hasPersistentKey(userId: UserId): Promise { + return await this.osBiometricsService.hasPersistentKey(userId); + } + + async enableWindowsV2Biometrics(): Promise { + if (this.platform === "win32" && !this.windowsV2BiometricsEnabled) { + this.logService.info("[BiometricsMain] Loading native biometrics module v2 for windows"); + this.osBiometricsService = new WindowsBiometricsSystem( + this.i18nService, + this.windowMain, + this.logService, + ); + this.windowsV2BiometricsEnabled = true; + } + } + + async isWindowsV2BiometricsEnabled(): Promise { + return this.windowsV2BiometricsEnabled; + } } diff --git a/apps/desktop/src/key-management/biometrics/native-v2/index.ts b/apps/desktop/src/key-management/biometrics/native-v2/index.ts new file mode 100644 index 0000000000..030224bbd7 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/index.ts @@ -0,0 +1 @@ +export { default as WindowsBiometricsSystem } from "./os-biometrics-windows.service"; diff --git a/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.spec.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.spec.ts new file mode 100644 index 0000000000..28b05c490b --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.spec.ts @@ -0,0 +1,126 @@ +import { mock } from "jest-mock-extended"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +import { biometrics_v2 } from "@bitwarden/desktop-napi"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { WindowMain } from "../../main/window.main"; + +import OsBiometricsServiceWindows from "./os-biometrics-windows.service"; + +jest.mock("@bitwarden/desktop-napi", () => ({ + biometrics_v2: { + initBiometricSystem: jest.fn(() => "mockSystem"), + provideKey: jest.fn(), + enrollPersistent: jest.fn(), + unenroll: jest.fn(), + unlock: jest.fn(), + authenticate: jest.fn(), + authenticateAvailable: jest.fn(), + unlockAvailable: jest.fn(), + hasPersistent: jest.fn(), + }, + passwords: { + isAvailable: jest.fn(), + }, +})); + +const mockKey = new Uint8Array(64); + +jest.mock("../../../utils", () => ({ + isFlatpak: jest.fn(() => false), + isLinux: jest.fn(() => true), + isSnapStore: jest.fn(() => false), +})); + +describe("OsBiometricsServiceWindows", () => { + const userId = "user-id" as UserId; + + let service: OsBiometricsServiceWindows; + let i18nService: I18nService; + let windowMain: WindowMain; + let logService: LogService; + + beforeEach(() => { + i18nService = mock(); + windowMain = mock(); + logService = mock(); + + windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(Buffer.from([1, 2, 3, 4])); + service = new OsBiometricsServiceWindows(i18nService, windowMain, logService); + }); + + it("should enroll persistent biometric key", async () => { + await service.enrollPersistent("user-id" as UserId, new SymmetricCryptoKey(mockKey)); + expect(biometrics_v2.enrollPersistent).toHaveBeenCalled(); + }); + + it("should set biometric key", async () => { + await service.setBiometricKey(userId, new SymmetricCryptoKey(mockKey)); + expect(biometrics_v2.provideKey).toHaveBeenCalled(); + }); + + it("should delete biometric key", async () => { + await service.deleteBiometricKey(userId); + expect(biometrics_v2.unenroll).toHaveBeenCalled(); + }); + + it("should get biometric key", async () => { + (biometrics_v2.unlock as jest.Mock).mockResolvedValue(mockKey); + const result = await service.getBiometricKey(userId); + expect(result).toBeInstanceOf(SymmetricCryptoKey); + }); + + it("should return null if no biometric key", async () => { + const error = new Error("No key found"); + (biometrics_v2.unlock as jest.Mock).mockRejectedValue(error); + const result = await service.getBiometricKey(userId); + expect(result).toBeNull(); + expect(logService.warning).toHaveBeenCalledWith( + `[OsBiometricsServiceWindows] Fetching the biometric key failed: ${error} returning null`, + ); + }); + + it("should authenticate biometric", async () => { + (biometrics_v2.authenticate as jest.Mock).mockResolvedValue(true); + const result = await service.authenticateBiometric(); + expect(result).toBe(true); + }); + + it("should check if biometrics is supported", async () => { + (biometrics_v2.authenticateAvailable as jest.Mock).mockResolvedValue(true); + const result = await service.supportsBiometrics(); + expect(result).toBe(true); + }); + + it("should return needs setup false", async () => { + const result = await service.needsSetup(); + expect(result).toBe(false); + }); + + it("should return auto setup false", async () => { + const result = await service.canAutoSetup(); + expect(result).toBe(false); + }); + + it("should get biometrics first unlock status for user", async () => { + (biometrics_v2.unlockAvailable as jest.Mock).mockResolvedValue(true); + const result = await service.getBiometricsFirstUnlockStatusForUser(userId); + expect(result).toBe(BiometricsStatus.Available); + }); + + it("should return false for hasPersistentKey false", async () => { + (biometrics_v2.hasPersistent as jest.Mock).mockResolvedValue(false); + const result = await service.hasPersistentKey(userId); + expect(result).toBe(false); + }); + + it("should return false for hasPersistentKey true", async () => { + (biometrics_v2.hasPersistent as jest.Mock).mockResolvedValue(true); + const result = await service.hasPersistentKey(userId); + expect(result).toBe(true); + }); +}); diff --git a/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.ts new file mode 100644 index 0000000000..4d9794daa7 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.ts @@ -0,0 +1,91 @@ +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +import { biometrics_v2 } from "@bitwarden/desktop-napi"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { WindowMain } from "../../../main/window.main"; +import { OsBiometricService } from "../os-biometrics.service"; + +export default class OsBiometricsServiceWindows implements OsBiometricService { + private biometricsSystem: biometrics_v2.BiometricLockSystem; + + constructor( + private i18nService: I18nService, + private windowMain: WindowMain, + private logService: LogService, + ) { + this.biometricsSystem = biometrics_v2.initBiometricSystem(); + } + + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { + await biometrics_v2.enrollPersistent( + this.biometricsSystem, + userId, + Buffer.from(key.toEncoded().buffer), + ); + } + + async hasPersistentKey(userId: UserId): Promise { + return await biometrics_v2.hasPersistent(this.biometricsSystem, userId); + } + + async supportsBiometrics(): Promise { + return await biometrics_v2.authenticateAvailable(this.biometricsSystem); + } + + async getBiometricKey(userId: UserId): Promise { + try { + const key = await biometrics_v2.unlock( + this.biometricsSystem, + userId, + this.windowMain.win.getNativeWindowHandle(), + ); + return key ? new SymmetricCryptoKey(Uint8Array.from(key)) : null; + } catch (error) { + this.logService.warning( + `[OsBiometricsServiceWindows] Fetching the biometric key failed: ${error} returning null`, + ); + return null; + } + } + + async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { + await biometrics_v2.provideKey( + this.biometricsSystem, + userId, + Buffer.from(key.toEncoded().buffer), + ); + } + + async deleteBiometricKey(userId: UserId): Promise { + await biometrics_v2.unenroll(this.biometricsSystem, userId); + } + + async authenticateBiometric(): Promise { + const hwnd = this.windowMain.win.getNativeWindowHandle(); + return await biometrics_v2.authenticate( + this.biometricsSystem, + hwnd, + this.i18nService.t("windowsHelloConsentMessage"), + ); + } + + async needsSetup() { + return false; + } + + async canAutoSetup(): Promise { + return false; + } + + async runSetup(): Promise {} + + async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { + return (await biometrics_v2.hasPersistent(this.biometricsSystem, userId)) || + (await biometrics_v2.unlockAvailable(this.biometricsSystem, userId)) + ? BiometricsStatus.Available + : BiometricsStatus.UnlockNeeded; + } +} diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts index 0ef3033b4c..400918a69b 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts @@ -47,6 +47,12 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { private logService: LogService, ) {} + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise {} + + async hasPersistentKey(userId: UserId): Promise { + return false; + } + private _iv: string | null = null; // Use getKeyMaterial helper instead of direct access private _osKeyHalf: string | null = null; diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts index 1dc64f1bcd..87d6397175 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts @@ -20,6 +20,14 @@ export default class OsBiometricsServiceMac implements OsBiometricService { private logService: LogService, ) {} + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { + return await passwords.setPassword(SERVICE, getLookupKeyForUser(userId), key.toBase64()); + } + + async hasPersistentKey(userId: UserId): Promise { + return (await passwords.getPassword(SERVICE, getLookupKeyForUser(userId))) != null; + } + async supportsBiometrics(): Promise { return systemPreferences.canPromptTouchID(); } diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts index 897304c9f6..a32d467842 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts @@ -35,6 +35,12 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { private cryptoFunctionService: CryptoFunctionService, ) {} + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise {} + + async hasPersistentKey(userId: UserId): Promise { + return false; + } + async supportsBiometrics(): Promise { return await biometrics.available(); } diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts index 63e0527c03..064b28f2ff 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts @@ -25,4 +25,6 @@ export interface OsBiometricService { setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise; deleteBiometricKey(userId: UserId): Promise; getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise; + enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise; + hasPersistentKey(userId: UserId): Promise; } diff --git a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts index c7ed88d390..bc3631ad1b 100644 --- a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts @@ -68,4 +68,20 @@ export class RendererBiometricsService extends DesktopBiometricsService { BiometricsStatus.ManualSetupNeeded, ].includes(biometricStatus); } + + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { + return await ipc.keyManagement.biometric.enrollPersistent(userId, key.toBase64()); + } + + async hasPersistentKey(userId: UserId): Promise { + return await ipc.keyManagement.biometric.hasPersistentKey(userId); + } + + async enableWindowsV2Biometrics(): Promise { + return await ipc.keyManagement.biometric.enableWindowsV2Biometrics(); + } + + async isWindowsV2BiometricsEnabled(): Promise { + return await ipc.keyManagement.biometric.isWindowsV2BiometricsEnabled(); + } } diff --git a/apps/desktop/src/key-management/preload.ts b/apps/desktop/src/key-management/preload.ts index 7f8576b847..a9565790b8 100644 --- a/apps/desktop/src/key-management/preload.ts +++ b/apps/desktop/src/key-management/preload.ts @@ -50,6 +50,25 @@ const biometric = { action: BiometricAction.SetShouldAutoprompt, data: should, } satisfies BiometricMessage), + enrollPersistent: (userId: string, keyB64: string): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.EnrollPersistent, + userId: userId, + key: keyB64, + } satisfies BiometricMessage), + hasPersistentKey: (userId: string): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.HasPersistentKey, + userId: userId, + } satisfies BiometricMessage), + enableWindowsV2Biometrics: (): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.EnableWindowsV2, + } satisfies BiometricMessage), + isWindowsV2BiometricsEnabled: (): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.IsWindowsV2Enabled, + } satisfies BiometricMessage), }; export default { diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index d3b3f6cd44..3e004e270a 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 993084f772..f8ea7551c4 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -82,7 +82,12 @@ export class WindowMain { ipcMain.on("window-hide", () => { if (this.win != null) { - this.win.hide(); + if (isWindows()) { + // On windows, to return focus we need minimize + this.win.minimize(); + } else { + this.win.hide(); + } } }); diff --git a/apps/desktop/src/services/biometric-message-handler.service.spec.ts b/apps/desktop/src/services/biometric-message-handler.service.spec.ts index ad555729ab..dec1e63d5e 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.spec.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.spec.ts @@ -13,13 +13,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; -import { DialogService, I18nMockService } from "@bitwarden/components"; -import { - KeyService, - BiometricsService, - BiometricStateService, - BiometricsCommands, -} from "@bitwarden/key-management"; +import { DialogService } from "@bitwarden/components"; +import { KeyService, BiometricsService, BiometricsCommands } from "@bitwarden/key-management"; +import { ConfigService } from "@bitwarden/services/config.service"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; @@ -47,15 +43,14 @@ describe("BiometricMessageHandlerService", () => { let keyService: MockProxy; let encryptService: MockProxy; let logService: MockProxy; + let configService: MockProxy; let messagingService: MockProxy; let desktopSettingsService: DesktopSettingsService; - let biometricStateService: BiometricStateService; let biometricsService: MockProxy; let dialogService: MockProxy; let accountService: AccountService; let authService: MockProxy; let ngZone: MockProxy; - let i18nService: MockProxy; beforeEach(() => { cryptoFunctionService = mock(); @@ -64,14 +59,13 @@ describe("BiometricMessageHandlerService", () => { logService = mock(); messagingService = mock(); desktopSettingsService = mock(); - biometricStateService = mock(); + configService = mock(); biometricsService = mock(); dialogService = mock(); accountService = new FakeAccountService(accounts); authService = mock(); ngZone = mock(); - i18nService = mock(); desktopSettingsService.browserIntegrationEnabled$ = of(false); desktopSettingsService.browserIntegrationFingerprintEnabled$ = of(false); @@ -94,7 +88,7 @@ describe("BiometricMessageHandlerService", () => { cryptoFunctionService.rsaEncrypt.mockResolvedValue( Utils.fromUtf8ToArray("encrypted") as CsprngArray, ); - + configService.getFeatureFlag.mockResolvedValue(false); service = new BiometricMessageHandlerService( cryptoFunctionService, keyService, @@ -102,13 +96,12 @@ describe("BiometricMessageHandlerService", () => { logService, messagingService, desktopSettingsService, - biometricStateService, biometricsService, dialogService, accountService, authService, ngZone, - i18nService, + configService, ); }); @@ -160,13 +153,12 @@ describe("BiometricMessageHandlerService", () => { logService, messagingService, desktopSettingsService, - biometricStateService, biometricsService, dialogService, accountService, authService, ngZone, - i18nService, + configService, ); }); @@ -511,4 +503,19 @@ describe("BiometricMessageHandlerService", () => { }, ); }); + + describe("init", () => { + it("enables Windows v2 biometrics when feature flag enabled", async () => { + configService.getFeatureFlag.mockReturnValue(true); + + await service.init(); + expect(biometricsService.enableWindowsV2Biometrics).toHaveBeenCalled(); + }); + it("does not enable Windows v2 biometrics when feature flag disabled", async () => { + configService.getFeatureFlag.mockReturnValue(false); + + await service.init(); + expect(biometricsService.enableWindowsV2Biometrics).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/desktop/src/services/biometric-message-handler.service.ts b/apps/desktop/src/services/biometric-message-handler.service.ts index 8b4c3744a8..6d07c4a2aa 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.ts @@ -4,25 +4,21 @@ import { combineLatest, concatMap, firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; -import { - BiometricStateService, - BiometricsCommands, - BiometricsService, - BiometricsStatus, - KeyService, -} from "@bitwarden/key-management"; +import { BiometricsCommands, BiometricsStatus, KeyService } from "@bitwarden/key-management"; import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component"; +import { DesktopBiometricsService } from "../key-management/biometrics/desktop.biometrics.service"; import { LegacyMessage, LegacyMessageWrapper } from "../models/native-messaging"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; @@ -82,13 +78,12 @@ export class BiometricMessageHandlerService { private logService: LogService, private messagingService: MessagingService, private desktopSettingService: DesktopSettingsService, - private biometricStateService: BiometricStateService, - private biometricsService: BiometricsService, + private biometricsService: DesktopBiometricsService, private dialogService: DialogService, private accountService: AccountService, private authService: AuthService, private ngZone: NgZone, - private i18nService: I18nService, + private configService: ConfigService, ) { combineLatest([ this.desktopSettingService.browserIntegrationEnabled$, @@ -119,6 +114,19 @@ export class BiometricMessageHandlerService { private connectedApps: ConnectedApps = new ConnectedApps(); + async init() { + this.logService.debug( + "[BiometricMessageHandlerService] Initializing biometric message handler", + ); + + const windowsV2Enabled = await this.configService.getFeatureFlag( + FeatureFlag.WindowsBiometricsV2, + ); + if (windowsV2Enabled) { + await this.biometricsService.enableWindowsV2Biometrics(); + } + } + async handleMessage(msg: LegacyMessageWrapper) { const { appId, message: rawMessage } = msg as LegacyMessageWrapper; diff --git a/apps/desktop/src/types/biometric-message.ts b/apps/desktop/src/types/biometric-message.ts index 9711b49496..a0a3967f46 100644 --- a/apps/desktop/src/types/biometric-message.ts +++ b/apps/desktop/src/types/biometric-message.ts @@ -13,6 +13,12 @@ export enum BiometricAction { GetShouldAutoprompt = "getShouldAutoprompt", SetShouldAutoprompt = "setShouldAutoprompt", + + EnrollPersistent = "enrollPersistent", + HasPersistentKey = "hasPersistentKey", + + EnableWindowsV2 = "enableWindowsV2", + IsWindowsV2Enabled = "isWindowsV2Enabled", } export type BiometricMessage = @@ -22,7 +28,15 @@ export type BiometricMessage = key: string; } | { - action: Exclude; + action: BiometricAction.EnrollPersistent; + userId: string; + key: string; + } + | { + action: Exclude< + BiometricAction, + BiometricAction.SetKeyForUser | BiometricAction.EnrollPersistent + >; userId?: string; data?: any; }; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 0ad63b630f..036a65a639 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -35,6 +35,7 @@ export enum FeatureFlag { EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation", ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption", + WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2", UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data", /* Tools */ @@ -115,6 +116,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EnrollAeadOnKeyRotation]: FALSE, [FeatureFlag.ForceUpdateKDFSettings]: FALSE, [FeatureFlag.PM25174_DisableType0Decryption]: FALSE, + [FeatureFlag.WindowsBiometricsV2]: FALSE, [FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE, /* Platform */