diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 70814c74106..04723698d73 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -573,6 +573,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" @@ -879,6 +892,8 @@ dependencies = [ "byteorder", "bytes", "cbc", + "chacha20", + "chacha20poly1305", "core-foundation", "desktop_objc", "dirs", @@ -896,8 +911,11 @@ dependencies = [ "rsa", "russh-cryptovec", "scopeguard", + "secmem-proc", "security-framework", "security-framework-sys", + "serde", + "serde_json", "sha2", "ssh-encoding", "ssh-key", @@ -2693,6 +2711,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.0.7", +] + [[package]] name = "rustversion" version = "1.0.20" @@ -2771,6 +2799,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secmem-proc" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473559b1d28f530c3a9b5f91a2866053e2b1c528a0e43dae83048139c99490c2" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "rustix 1.0.7", + "rustix-linux-procfs", + "thiserror 2.0.12", + "windows 0.61.1", +] + [[package]] name = "security-framework" version = "3.1.0" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 21835c61585..1e2c29da70d 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -14,7 +14,6 @@ anyhow = "=1.0.94" arboard = { version = "=3.6.0", default-features = false } ashpd = "=0.11.0" base64 = "=0.22.1" -bindgen = "=0.72.0" bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" } byteorder = "=1.5.0" bytes = "=1.10.1" @@ -41,6 +40,7 @@ rand = "=0.9.1" rsa = "=0.9.6" russh-cryptovec = "=0.7.3" scopeguard = "=1.2.0" +secmem-proc = "=0.3.7" security-framework = "=3.1.0" security-framework-sys = "=2.13.0" serde = "=1.0.209" diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index 50c07b56600..6a58c30b015 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -35,6 +35,7 @@ log = { workspace = true } rand = { workspace = true } russh-cryptovec = { workspace = true } scopeguard = { workspace = true } +secmem-proc = { workspace = true } sha2 = { workspace = true } ssh-encoding = { workspace = true } ssh-key = { workspace = true, features = [ @@ -55,6 +56,11 @@ ed25519 = { workspace = true, features = ["pkcs8"] } bytes = { workspace = true } sysinfo = { workspace = true, features = ["windows"] } zeroizing-alloc = { workspace = true } +chacha20 = "0.9.1" +chacha20poly1305 = "0.10.1" +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +windows = { workspace = true, features = ["UI_Accessibility"] } [target.'cfg(windows)'.dependencies] widestring = { workspace = true, optional = true } @@ -65,6 +71,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 00000000000..28c7a2b3c2a --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs @@ -0,0 +1,40 @@ +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_attr(target_os = "windows", path = "windows_focus.rs")] +mod windows_focus; + +pub use biometric_v2::BiometricLockSystem; + +#[allow(async_fn_in_trait)] +pub trait BiometricV2Trait { + /// 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 + async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()>; + async fn has_persistent(&self, user_id: &str) -> Result; + /// On every unlock, the client provides a key to be held for subsequent biometric 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 00000000000..187bd2b9de8 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs @@ -0,0 +1,37 @@ +pub struct Biometric {} + +impl super::BiometricV2Trait for Biometric { + async fn authorize(_hwnd: Vec, _message: String) -> Result { + unimplemented!() + } + + async fn available_available() -> Result { + Ok(false) + } + + async fn enroll_persistent( + user_id: &str, + key: &[u8] + ) -> Result { + unimplemented!() + } + + async fn provide_userkey( + user_id: &str, + key: &[u8] + ) -> Result { + unimplemented!() + } + + async fn unlock( + user_id: &str + ) -> Result { + unimplemented!() + } + + async fn unlock_available( + user_id: &str, + ) -> Result { + Ok(false) + } +} \ No newline at end of file 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 00000000000..b42c22f4bdb --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs @@ -0,0 +1,257 @@ +//! This file implements Windows-Hello based biometric unlock. +//! +//! # Security +//! Note: There are two scenarios to consider, with different security implications. This section +//! describes the assumed security model and security guarantees achieved. In the required security +//! guarantee is that a locked vault - a running app - cannot be unlocked when the device (user-space) +//! is compromised in this state. +//! +//! 1. Require master password on app restart +//! In this scenario, 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. +//! +//! 2. Do not require master password on app restart +//! 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::{ffi::c_void, sync::{atomic::AtomicBool, Arc}}; + +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, HSTRING}, + Security::{Credentials::{KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus, UI::{ + UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability, + }}, Cryptography::CryptographicBuffer}, + Win32::{ + Foundation::HWND, System::WinRT::IUserConsentVerifierInterop, + UI::WindowsAndMessaging::GetForegroundWindow, + }, +}; +use windows_future::IAsyncOperation; + +use super::windows_focus::{focus_security_prompt, set_focus}; +use crate::{ + password, secure_memory::* +}; + +const KEYCHAIN_SERVICE_NAME: &str = "BitwardenBiometricsV2"; + +#[derive(serde::Serialize, serde::Deserialize)] +struct WindowsHelloKeychainEntry { + nonce: [u8; 24], + challenge: [u8; 16], + 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 super::BiometricV2Trait for BiometricLockSystem { + async fn authenticate(&self, hwnd: Vec, message: String) -> Result { + windows_hello_authenticate(hwnd, message) + } + + async fn authenticate_available(&self) -> Result { + match UserConsentVerifier::CheckAvailabilityAsync()?.get()? { + UserConsentVerifierAvailability::Available => Ok(true), + UserConsentVerifierAvailability::DeviceBusy => Ok(true), + _ => Ok(false), + } + } + + 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 mut challenge = [0u8; 16]; + rand::fill(&mut challenge); + + // This key is unique to the challenge + let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge)?; + + let nonce = { + let mut nonce_bytes = [0u8; 24]; + rand::fill(&mut nonce_bytes); + XNonce::clone_from_slice(&nonce_bytes) + }; + + let wrapped_key = XChaCha20Poly1305::new(&windows_hello_key.into()).encrypt(&nonce, key).map_err(|e| anyhow!(e))?; + set_keychain_entry(user_id, &WindowsHelloKeychainEntry { + nonce: nonce.as_slice().try_into().map_err(|_| anyhow!("Invalid nonce length"))?, + challenge, + wrapped_key, + }).await?; + Ok(()) + } + + async fn provide_key(&self, user_id: &str, key: &[u8]) { + let mut secure_memory = self.secure_memory.lock().await; + secure_memory.put(user_id.to_string(), key); + } + + async fn unlock(&self, user_id: &str, hwnd: Vec) -> Result> { + let mut secure_memory = self.secure_memory.lock().await; + if secure_memory.has(user_id) { + println!("[Windows Hello] Key is in secure memory, using UV API"); + + if self.authenticate(hwnd, "Unlock your vault".to_owned()).await? { + println!("[Windows Hello] Authentication successful"); + return secure_memory.get(user_id).clone().ok_or_else(|| anyhow!("No key found for user")); + } + Err(anyhow!("Authentication failed")) + } else { + println!("[Windows Hello] Key not in secure memory, using Signing API"); + + let keychain_entry = get_keychain_entry(user_id).await?; + let windows_hello_key = windows_hello_authenticate_with_crypto(&keychain_entry.challenge)?; + let decrypted_key = XChaCha20Poly1305::new(&windows_hello_key.into()).decrypt(keychain_entry.nonce.as_slice().try_into().map_err(|_| anyhow!("Invalid nonce length"))?, keychain_entry.wrapped_key.as_slice()).map_err(|e| anyhow!(e))?; + 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 +fn windows_hello_authenticate(hwnd: Vec, message: String) -> Result { + let h = isize::from_le_bytes(hwnd.clone().try_into().unwrap()); + let h = h as *mut c_void; + let window = HWND(h); + + // The Windows Hello prompt is displayed inside the application window. For best result we + // should set the window to the foreground and focus it. + set_focus(window); + + // 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 = unsafe { GetForegroundWindow() }; + + let interop = factory::()?; + let operation: IAsyncOperation = unsafe { + interop.RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))? + }; + let result = operation.get()?; + + match result { + 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 +fn windows_hello_authenticate_with_crypto(challenge: &[u8; 16]) -> Result<[u8; 32]> { + // 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 exists. 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 signing key + let result = KeyCredentialManager::RequestCreateAsync( + h!("BitwardenBiometricsV2"), + KeyCredentialCreationOption::FailIfExists, + )? + .get()?; + let result = match result.Status()? { + KeyCredentialStatus::CredentialAlreadyExists => { + KeyCredentialManager::OpenAsync(h!("BitwardenBiometricsV2"))?.get()? + } + KeyCredentialStatus::Success => result, + _ => return Err(anyhow!("Failed to create key credential")), + }; + + let signature = result.Credential()?.RequestSignAsync(&CryptographicBuffer::CreateFromByteArray(challenge.as_slice())?)?.get()?; + + if signature.Status()? == KeyCredentialStatus::Success { + let signature_buffer = signature.Result()?; + let mut signature_value = + windows::core::Array::::with_len(signature_buffer.Length().unwrap() as usize); + CryptographicBuffer::CopyToByteArray(&signature_buffer, &mut signature_value)?; + + // 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. + Ok(Sha256::digest(signature_value.as_slice()).into()) + } else { + Err(anyhow!("Failed to sign data")) + } +} + +async fn set_keychain_entry(user_id: &str, entry: &WindowsHelloKeychainEntry) -> Result<()> { + let serialized_entry = serde_json::to_string(entry)?; + + password::set_password( + KEYCHAIN_SERVICE_NAME, + user_id, + &serialized_entry, + ).await?; + + Ok(()) +} + +async fn get_keychain_entry(user_id: &str) -> Result { + let entry_str = password::get_password(KEYCHAIN_SERVICE_NAME, user_id).await?; + let entry: WindowsHelloKeychainEntry = serde_json::from_str(&entry_str)?; + Ok(entry) +} + +async fn has_keychain_entry(user_id: &str) -> Result { + let entry = password::get_password(KEYCHAIN_SERVICE_NAME, user_id).await?; + Ok(!entry.is_empty()) +} 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 00000000000..6b338a04806 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs @@ -0,0 +1,73 @@ +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}, + } + }, +}; + +/// 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 class_name = s!("Credential Dialog Xaml Host"); + let hwnd = unsafe { FindWindowA(class_name, None) }; + if let Ok(hwnd) = hwnd { + set_focus(hwnd); + } +} + +pub(crate) fn set_focus(window: 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. + + // Attach to the foreground thread once attached, we can foregroud, even if in the background + // Update the foreground lock timeout temporarily + let mut old_timeout = 0; + 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), + ); + SystemParametersInfoW(SPI_SETFOREGROUNDLOCKTIMEOUT, 0, None, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE); + let _scopeguard = scopeguard::guard((), |_| { + SystemParametersInfoW(SPI_SETFOREGROUNDLOCKTIMEOUT, old_timeout, None, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE); + }); + + // Attach to the active window's thread + let dwCurrentThread = GetCurrentThreadId(); + let dwFGThread = GetWindowThreadProcessId(GetForegroundWindow(), None); + + AttachThreadInput(dwCurrentThread, dwFGThread, true); + + let hwnd = window; + SetForegroundWindow(hwnd); + SetCapture(hwnd); + SetFocus(Some(hwnd)); + SetActiveWindow(hwnd); + EnableWindow(hwnd, true); + BringWindowToTop(hwnd); + SwitchToThisWindow(hwnd, true); + + AttachThreadInput(dwCurrentThread, dwFGThread, false); + } +} diff --git a/apps/desktop/desktop_native/core/src/lib.rs b/apps/desktop/desktop_native/core/src/lib.rs index a72ec04e9c2..2208edcf586 100644 --- a/apps/desktop/desktop_native/core/src/lib.rs +++ b/apps/desktop/desktop_native/core/src/lib.rs @@ -9,6 +9,8 @@ pub mod password; pub mod powermonitor; pub mod process_isolation; pub mod ssh_agent; +pub(crate) mod secure_memory; +pub mod biometric_v2; 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 00000000000..b9ca7f7264f --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs @@ -0,0 +1,124 @@ +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) { + if let Some(mut value) = self.map.remove(key) { + unsafe { + std::ptr::write_bytes(value.as_mut_ptr(), 0, value.len()); + } + } + } + + fn clear(&mut self) { + for (_, mut value) in self.map.drain() { + unsafe { + std::ptr::write_bytes(value.as_mut_ptr(), 0, value.len()); + } + } + } +} + +impl Drop for DpapiSecretKVStore { + fn drop(&mut self) { + self.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dpapi_secret_kv_store() { + 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)); + } +} \ No newline at end of file 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 00000000000..798a19d49ea --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs @@ -0,0 +1,41 @@ +#[cfg(target_os = "windows")] +pub(crate) mod dpapi; +#[cfg(target_os = "linux")] +mod unimplemented; +#[cfg(target_os = "macos")] +mod unimplemented; + +/// 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. +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); +} + +/// Creates a new secure memory store based on the platform. +pub fn create_secure_memory_store() -> Box { + #[cfg(target_os = "linux")] + { + unimplemented!() + } + #[cfg(target_os = "windows")] + { + Box::new(dpapi::DpapiSecretKVStore::new()) + } + #[cfg(target_os = "macos")] + { + unimplemented!() + } +} \ No newline at end of file diff --git a/apps/desktop/desktop_native/core/src/secure_memory/unimplemented.rs b/apps/desktop/desktop_native/core/src/secure_memory/unimplemented.rs new file mode 100644 index 00000000000..c526277b358 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/unimplemented.rs @@ -0,0 +1,84 @@ +use crate::secure_memory::SecureMemoryStore; + +pub struct UnimplementedKVStore {} + +impl UnimplementedKVStore { + pub(super) fn new() -> Self { + UnimplementedKVStore { + } + } +} + +impl SecureMemoryStore for UnimplementedKVStore { + fn put(&mut self, key: String, value: &[u8]) { + + } + + 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) { + if let Some(mut value) = self.map.remove(key) { + unsafe { + std::ptr::write_bytes(value.as_mut_ptr(), 0, value.len()); + } + } + } + + fn clear(&mut self) { + for (_, mut value) in self.map.drain() { + unsafe { + std::ptr::write_bytes(value.as_mut_ptr(), 0, value.len()); + } + } + } +} + +impl Drop for DpapiSecretKVStore { + fn drop(&mut self) { + self.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dpapi_secret_kv_store() { + 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)); + } +} \ No newline at end of file diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 5ea75bd6120..f8f76f06f97 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -21,6 +21,17 @@ export declare namespace passwords { /** Checks if the os secure storage is available */ export function isAvailable(): Promise } +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 class BiometricLockSystem { } +} export declare namespace biometrics { export function prompt(hwnd: Buffer, message: string): Promise export function available(): Promise diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index d0a57b5632a..f9337d1bc5d 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -49,6 +49,71 @@ pub mod passwords { } } +#[napi] +pub mod biometrics_v2 { + use desktop_core::biometric_v2::{BiometricV2Trait}; + + #[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 mod biometrics { use desktop_core::biometric::{Biometric, BiometricTrait}; 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 897304c9f61..67c6291c5ea 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 @@ -6,7 +6,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.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 { biometrics, passwords } from "@bitwarden/desktop-napi"; +import { biometrics, passwords, biometrics_v2 } from "@bitwarden/desktop-napi"; import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management"; import { WindowMain } from "../../main/window.main"; @@ -26,6 +26,8 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { private _osKeyHalf: string | null = null; private clientKeyHalves = new Map(); + private biometricsSystem = biometrics_v2.initBiometricSystem(); + constructor( private i18nService: I18nService, private windowMain: WindowMain, @@ -40,64 +42,16 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { } async getBiometricKey(userId: UserId): Promise { - const success = await this.authenticateBiometric(); - if (!success) { - return null; - } - - const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId)); - if (value == null || value == "") { - throw new Error("Biometric key not found for user"); - } - - let clientKeyHalfB64: string | null = null; - if (this.clientKeyHalves.has(userId)) { - clientKeyHalfB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)!); - } - - if (!EncString.isSerializedEncString(value)) { - // Update to format encrypted with client key half - const storageDetails = await this.getStorageDetails({ - clientKeyHalfB64: clientKeyHalfB64 ?? undefined, - }); - - await biometrics.setBiometricSecret( - SERVICE, - getLookupKeyForUser(userId), - value, - storageDetails.key_material, - storageDetails.ivB64, - ); - return SymmetricCryptoKey.fromString(value); - } else { - const encValue = new EncString(value); - this.setIv(encValue.iv); - const storageDetails = await this.getStorageDetails({ - clientKeyHalfB64: clientKeyHalfB64 ?? undefined, - }); - return SymmetricCryptoKey.fromString( - await biometrics.getBiometricSecret( - SERVICE, - getLookupKeyForUser(userId), - storageDetails.key_material, - ), - ); - } + const key = await biometrics_v2.unlock( + this.biometricsSystem, + userId, + this.windowMain.win.getNativeWindowHandle(), + ); + return new SymmetricCryptoKey(Uint8Array.from(key)); } async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { - const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); - - const storageDetails = await this.getStorageDetails({ - clientKeyHalfB64: Utils.fromBufferToB64(clientKeyHalf), - }); - await biometrics.setBiometricSecret( - SERVICE, - getLookupKeyForUser(userId), - key.toBase64(), - storageDetails.key_material, - storageDetails.ivB64, - ); + return; } async deleteBiometricKey(userId: UserId): Promise { @@ -199,10 +153,6 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { } async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { - if (this.clientKeyHalves.has(userId)) { - return BiometricsStatus.Available; - } else { - return BiometricsStatus.UnlockNeeded; - } + return BiometricsStatus.Available; } } diff --git a/apps/desktop/src/main/native-messaging.main.ts b/apps/desktop/src/main/native-messaging.main.ts index 93525164ff5..0d266819a42 100644 --- a/apps/desktop/src/main/native-messaging.main.ts +++ b/apps/desktop/src/main/native-messaging.main.ts @@ -13,6 +13,8 @@ import { isDev } from "../utils"; import { WindowMain } from "./window.main"; +const LOG_MESSAGE_CONTENT = false; + export class NativeMessagingMain { private ipcServer: ipc.IpcServer | null; private connected: number[] = []; @@ -97,7 +99,9 @@ export class NativeMessagingMain { case ipc.IpcMessageType.Message: try { const msgJson = JSON.parse(msg.message); - this.logService.debug("Native messaging message:", msgJson); + if (LOG_MESSAGE_CONTENT) { + this.logService.debug("Native messaging message:", msgJson); + } this.windowMain.win?.webContents.send("nativeMessaging", msgJson); } catch (e) { this.logService.warning("Error processing message:", e, msg.message); @@ -124,7 +128,9 @@ export class NativeMessagingMain { } send(message: object) { - this.logService.debug("Native messaging reply:", message); + if (LOG_MESSAGE_CONTENT) { + this.logService.debug("Native messaging reply:", message); + } this.ipcServer?.send(JSON.stringify(message)); } diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 5b81cf8140b..2bbcaa71d42 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -74,15 +74,24 @@ export class WindowMain { }); ipcMain.on("window-focus", () => { + this.logService.info("Focusing window"); if (this.win != null) { - this.win.show(); - this.win.focus(); + this.logService.info("Showing window"); + this.win.minimize(); + this.show(); + this.win.setSize(this.defaultWidth, this.defaultHeight); + this.win.center(); } }); ipcMain.on("window-hide", () => { - if (this.win != null) { - this.win.hide(); + if (this.win != null) { + 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.ts b/apps/desktop/src/services/biometric-message-handler.service.ts index 8b4c3744a8d..38f7e331312 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.ts @@ -25,6 +25,9 @@ import { import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component"; import { LegacyMessage, LegacyMessageWrapper } from "../models/native-messaging"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; +import { isWindows } from "../utils"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DeviceType } from "@bitwarden/common/enums"; const MessageValidTimeout = 10 * 1000; const HashAlgorithmForAsymmetricEncryption = "sha1"; @@ -89,6 +92,7 @@ export class BiometricMessageHandlerService { private authService: AuthService, private ngZone: NgZone, private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService ) { combineLatest([ this.desktopSettingService.browserIntegrationEnabled$, @@ -350,6 +354,7 @@ export class BiometricMessageHandlerService { // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { + this.logService.error("[Native Messaging IPC] Biometric unlock failed", e); await this.send( { command: BiometricsCommands.UnlockWithBiometricsForUser, messageId, response: false }, appId, diff --git a/package-lock.json b/package-lock.json index 5f4aedffd1f..fdd125c06d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -403,6 +403,7 @@ "license": "GPL-3.0" }, "libs/state-internal": { + "name": "@bitwarden/state-internal", "version": "0.0.1", "license": "GPL-3.0" },