From 1ae7c3004713788a2f8e654097c9cd1df2e8bee6 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 4 Sep 2025 21:05:49 +0200 Subject: [PATCH] Add feature flag and re-add old impl --- .../core/src/biometric/macos.rs | 38 ++ .../desktop_native/core/src/biometric/mod.rs | 186 +++++- .../desktop_native/core/src/biometric/unix.rs | 109 ++++ .../core/src/biometric/windows.rs | 539 ++++++------------ .../core/src/biometric/windows_focus.rs | 80 +-- .../src/{biometric => biometric_v2}/linux.rs | 0 .../core/src/biometric_v2/mod.rs | 33 ++ .../unimplemented.rs | 0 .../core/src/biometric_v2/windows.rs | 412 +++++++++++++ .../core/src/biometric_v2/windows_focus.rs | 92 +++ .../core/src/crypto/cipher_string.rs | 212 +++++++ .../desktop_native/core/src/crypto/crypto.rs | 35 ++ .../desktop_native/core/src/crypto/mod.rs | 8 + apps/desktop/desktop_native/core/src/lib.rs | 2 + apps/desktop/desktop_native/napi/index.d.ts | 32 +- apps/desktop/desktop_native/napi/src/lib.rs | 107 +++- .../biometrics/desktop.biometrics.service.ts | 2 + .../main-biometrics-ipc.listener.ts | 2 + .../biometrics/main-biometrics.service.ts | 44 +- .../os-biometrics-linux.service.spec.ts | 86 +++ .../native-v1/os-biometrics-linux.service.ts | 236 ++++++++ .../os-biometrics-mac.service.spec.ts | 0 .../native-v1/os-biometrics-mac.service.ts | 109 ++++ .../os-biometrics-windows.service.spec.ts | 378 ++++++++++++ .../os-biometrics-windows.service.ts | 213 +++++++ .../biometrics/native-v2/index.ts | 3 + .../os-biometrics-linux.service.spec.ts | 0 .../os-biometrics-linux.service.ts | 26 +- .../os-biometrics-mac.service.spec.ts | 78 +++ .../os-biometrics-mac.service.ts | 0 .../os-biometrics-windows.service.spec.ts | 0 .../os-biometrics-windows.service.ts | 34 +- .../{ => native-v2}/os-biometrics.service.ts | 0 .../biometrics/renderer-biometrics.service.ts | 4 + apps/desktop/src/key-management/preload.ts | 4 + .../biometric-message-handler.service.ts | 37 +- apps/desktop/src/types/biometric-message.ts | 2 + libs/common/src/enums/feature-flag.enum.ts | 2 + package-lock.json | 3 +- 39 files changed, 2649 insertions(+), 499 deletions(-) create mode 100644 apps/desktop/desktop_native/core/src/biometric/macos.rs create mode 100644 apps/desktop/desktop_native/core/src/biometric/unix.rs rename apps/desktop/desktop_native/core/src/{biometric => biometric_v2}/linux.rs (100%) create mode 100644 apps/desktop/desktop_native/core/src/biometric_v2/mod.rs rename apps/desktop/desktop_native/core/src/{biometric => biometric_v2}/unimplemented.rs (100%) create mode 100644 apps/desktop/desktop_native/core/src/biometric_v2/windows.rs create mode 100644 apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs create mode 100644 apps/desktop/desktop_native/core/src/crypto/cipher_string.rs create mode 100644 apps/desktop/desktop_native/core/src/crypto/crypto.rs create mode 100644 apps/desktop/desktop_native/core/src/crypto/mod.rs create mode 100644 apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-linux.service.spec.ts create mode 100644 apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-linux.service.ts rename apps/desktop/src/key-management/biometrics/{ => native-v1}/os-biometrics-mac.service.spec.ts (100%) create mode 100644 apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-mac.service.ts create mode 100644 apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-windows.service.spec.ts create mode 100644 apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-windows.service.ts create mode 100644 apps/desktop/src/key-management/biometrics/native-v2/index.ts rename apps/desktop/src/key-management/biometrics/{ => native-v2}/os-biometrics-linux.service.spec.ts (100%) rename apps/desktop/src/key-management/biometrics/{ => native-v2}/os-biometrics-linux.service.ts (82%) create mode 100644 apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-mac.service.spec.ts rename apps/desktop/src/key-management/biometrics/{ => native-v2}/os-biometrics-mac.service.ts (100%) rename apps/desktop/src/key-management/biometrics/{ => native-v2}/os-biometrics-windows.service.spec.ts (100%) rename apps/desktop/src/key-management/biometrics/{ => native-v2}/os-biometrics-windows.service.ts (65%) rename apps/desktop/src/key-management/biometrics/{ => native-v2}/os-biometrics.service.ts (100%) diff --git a/apps/desktop/desktop_native/core/src/biometric/macos.rs b/apps/desktop/desktop_native/core/src/biometric/macos.rs new file mode 100644 index 00000000000..ec09d566e1f --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric/macos.rs @@ -0,0 +1,38 @@ +use anyhow::{bail, Result}; + +use crate::biometric::{KeyMaterial, OsDerivedKey}; + +/// The MacOS implementation of the biometric trait. +pub struct Biometric {} + +impl super::BiometricTrait for Biometric { + async fn prompt(_hwnd: Vec, _message: String) -> Result { + bail!("platform not supported"); + } + + async fn available() -> Result { + bail!("platform not supported"); + } + + fn derive_key_material(_iv_str: Option<&str>) -> Result { + bail!("platform not supported"); + } + + async fn get_biometric_secret( + _service: &str, + _account: &str, + _key_material: Option, + ) -> Result { + bail!("platform not supported"); + } + + async fn set_biometric_secret( + _service: &str, + _account: &str, + _secret: &str, + _key_material: Option, + _iv_b64: &str, + ) -> Result { + bail!("platform not supported"); + } +} diff --git a/apps/desktop/desktop_native/core/src/biometric/mod.rs b/apps/desktop/desktop_native/core/src/biometric/mod.rs index 8b00559a6e2..4911ec54b4a 100644 --- a/apps/desktop/desktop_native/core/src/biometric/mod.rs +++ b/apps/desktop/desktop_native/core/src/biometric/mod.rs @@ -1,33 +1,177 @@ -use anyhow::Result; +//! Note: This module is deprecated and will be deleted after the v2 module is tested and rolled out. + +use aes::cipher::generic_array::GenericArray; +use anyhow::{anyhow, Result}; #[allow(clippy::module_inception)] -#[cfg_attr(target_os = "linux", path = "linux.rs")] -#[cfg_attr(target_os = "macos", path = "unimplemented.rs")] +#[cfg_attr(target_os = "linux", path = "unix.rs")] +#[cfg_attr(target_os = "macos", path = "macos.rs")] #[cfg_attr(target_os = "windows", path = "windows.rs")] mod biometric; +pub use biometric::Biometric; + #[cfg(target_os = "windows")] pub mod windows_focus; -pub use biometric::BiometricLockSystem; +use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; +use sha2::{Digest, Sha256}; + +use crate::crypto::{self, CipherString}; + +pub struct KeyMaterial { + pub os_key_part_b64: String, + pub client_key_part_b64: Option, +} + +pub struct OsDerivedKey { + pub key_b64: String, + pub iv_b64: String, +} #[allow(async_fn_in_trait)] pub trait BiometricTrait { - /// 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 the 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; + async fn prompt(hwnd: Vec, message: String) -> Result; + async fn available() -> Result; + fn derive_key_material(secret: Option<&str>) -> Result; + async fn set_biometric_secret( + service: &str, + account: &str, + secret: &str, + key_material: Option, + iv_b64: &str, + ) -> Result; + async fn get_biometric_secret( + service: &str, + account: &str, + key_material: Option, + ) -> Result; +} + +#[allow(unused)] +fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result { + let iv = base64_engine + .decode(iv_b64)? + .try_into() + .map_err(|e: Vec<_>| anyhow!("Expected length {}, got {}", 16, e.len()))?; + + let encrypted = crypto::encrypt_aes256(secret.as_bytes(), iv, key_material.derive_key()?)?; + + Ok(encrypted.to_string()) +} + +#[allow(unused)] +fn decrypt(secret: &CipherString, key_material: &KeyMaterial) -> Result { + if let CipherString::AesCbc256_B64 { iv, data } = secret { + let decrypted = crypto::decrypt_aes256(iv, data, key_material.derive_key()?)?; + + Ok(String::from_utf8(decrypted)?) + } else { + Err(anyhow!("Invalid cipher string")) + } +} + +impl KeyMaterial { + fn digest_material(&self) -> String { + match self.client_key_part_b64.as_deref() { + Some(client_key_part_b64) => { + format!("{}|{}", self.os_key_part_b64, client_key_part_b64) + } + None => self.os_key_part_b64.clone(), + } + } + + pub fn derive_key(&self) -> Result> { + Ok(Sha256::digest(self.digest_material())) + } +} + +#[cfg(test)] +mod tests { + use crate::biometric::{decrypt, encrypt, KeyMaterial}; + use crate::crypto::CipherString; + use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; + use std::str::FromStr; + + fn key_material() -> KeyMaterial { + KeyMaterial { + os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), + client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()), + } + } + + #[test] + fn test_encrypt() { + let key_material = key_material(); + let iv_b64 = "l9fhDUP/wDJcKwmEzcb/3w==".to_owned(); + let secret = encrypt("secret", &key_material, &iv_b64) + .unwrap() + .parse::() + .unwrap(); + + match secret { + CipherString::AesCbc256_B64 { iv, data: _ } => { + assert_eq!(iv_b64, base64_engine.encode(iv)); + } + _ => panic!("Invalid cipher string"), + } + } + + #[test] + fn test_decrypt() { + let secret = + CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt + let key_material = key_material(); + assert_eq!(decrypt(&secret, &key_material).unwrap(), "secret") + } + + #[test] + fn key_material_produces_valid_key() { + let result = key_material().derive_key().unwrap(); + assert_eq!(result.len(), 32); + } + + #[test] + fn key_material_uses_os_part() { + let mut key_material = key_material(); + let result = key_material.derive_key().unwrap(); + key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(); + let result2 = key_material.derive_key().unwrap(); + assert_ne!(result, result2); + } + + #[test] + fn key_material_uses_client_part() { + let mut key_material = key_material(); + let result = key_material.derive_key().unwrap(); + key_material.client_key_part_b64 = + Some("BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()); + let result2 = key_material.derive_key().unwrap(); + assert_ne!(result, result2); + } + + #[test] + fn key_material_produces_consistent_os_only_key() { + let mut key_material = key_material(); + key_material.client_key_part_b64 = None; + let result = key_material.derive_key().unwrap(); + assert_eq!( + result, + [ + 81, 100, 62, 172, 151, 119, 182, 58, 123, 38, 129, 116, 209, 253, 66, 118, 218, + 237, 236, 155, 201, 234, 11, 198, 229, 171, 246, 144, 71, 188, 84, 246 + ] + .into() + ); + } + + #[test] + fn key_material_produces_unique_os_only_key() { + let mut key_material = key_material(); + key_material.client_key_part_b64 = None; + let result = key_material.derive_key().unwrap(); + key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(); + let result2 = key_material.derive_key().unwrap(); + assert_ne!(result, result2); + } } diff --git a/apps/desktop/desktop_native/core/src/biometric/unix.rs b/apps/desktop/desktop_native/core/src/biometric/unix.rs new file mode 100644 index 00000000000..60392adc9d7 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric/unix.rs @@ -0,0 +1,109 @@ +use std::str::FromStr; + +use anyhow::Result; +use base64::Engine; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +use crate::biometric::{base64_engine, KeyMaterial, OsDerivedKey}; +use zbus::Connection; +use zbus_polkit::policykit1::*; + +use super::{decrypt, encrypt}; +use crate::crypto::CipherString; +use anyhow::anyhow; + +/// The Unix implementation of the biometric trait. +pub struct Biometric {} + +impl super::BiometricTrait for Biometric { + async fn prompt(_hwnd: Vec, _message: String) -> Result { + let connection = Connection::system().await?; + let proxy = AuthorityProxy::new(&connection).await?; + let subject = Subject::new_for_owner(std::process::id(), None, None)?; + let details = std::collections::HashMap::new(); + let result = proxy + .check_authorization( + &subject, + "com.bitwarden.Bitwarden.unlock", + &details, + CheckAuthorizationFlags::AllowUserInteraction.into(), + "", + ) + .await; + + match result { + Ok(result) => Ok(result.is_authorized), + Err(e) => { + println!("polkit biometric error: {:?}", e); + Ok(false) + } + } + } + + async fn available() -> Result { + let connection = Connection::system().await?; + let proxy = AuthorityProxy::new(&connection).await?; + let res = proxy.enumerate_actions("en").await?; + for action in res { + if action.action_id == "com.bitwarden.Bitwarden.unlock" { + return Ok(true); + } + } + Ok(false) + } + + fn derive_key_material(challenge_str: Option<&str>) -> Result { + let challenge: [u8; 16] = match challenge_str { + Some(challenge_str) => base64_engine + .decode(challenge_str)? + .try_into() + .map_err(|e: Vec<_>| anyhow!("Expect length {}, got {}", 16, e.len()))?, + None => random_challenge(), + }; + + // there is no windows hello like interactive bio protected secret at the moment on linux + // so we use a a key derived from the iv. this key is not intended to add any security + // but only a place-holder + let key = Sha256::digest(challenge); + let key_b64 = base64_engine.encode(key); + let iv_b64 = base64_engine.encode(challenge); + Ok(OsDerivedKey { key_b64, iv_b64 }) + } + + async fn set_biometric_secret( + service: &str, + account: &str, + secret: &str, + key_material: Option, + iv_b64: &str, + ) -> Result { + let key_material = key_material.ok_or(anyhow!( + "Key material is required for polkit protected keys" + ))?; + + let encrypted_secret = encrypt(secret, &key_material, iv_b64)?; + crate::password::set_password(service, account, &encrypted_secret).await?; + Ok(encrypted_secret) + } + + async fn get_biometric_secret( + service: &str, + account: &str, + key_material: Option, + ) -> Result { + let key_material = key_material.ok_or(anyhow!( + "Key material is required for polkit protected keys" + ))?; + + let encrypted_secret = crate::password::get_password(service, account).await?; + let secret = CipherString::from_str(&encrypted_secret)?; + decrypt(&secret, &key_material) + } +} + +fn random_challenge() -> [u8; 16] { + let mut challenge = [0u8; 16]; + rand::rng().fill_bytes(&mut challenge); + challenge +} diff --git a/apps/desktop/desktop_native/core/src/biometric/windows.rs b/apps/desktop/desktop_native/core/src/biometric/windows.rs index 881075ef29f..8013c21bf9a 100644 --- a/apps/desktop/desktop_native/core/src/biometric/windows.rs +++ b/apps/desktop/desktop_native/core/src/biometric/windows.rs @@ -1,412 +1,241 @@ -//! 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::{ffi::c_void, str::FromStr}; -use std::sync::{atomic::AtomicBool, Arc}; - -use aes::cipher::KeyInit; use anyhow::{anyhow, Result}; -use chacha20poly1305::{aead::Aead, XChaCha20Poly1305, XNonce}; +use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; +use rand::RngCore; 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, + core::{factory, HSTRING}, + Security::Credentials::UI::{ + UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability, }, Win32::{ - System::WinRT::IUserConsentVerifierInterop, UI::WindowsAndMessaging::GetForegroundWindow, + 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::*}; +use crate::{ + biometric::{KeyMaterial, OsDerivedKey}, + crypto::CipherString, +}; -const KEYCHAIN_SERVICE_NAME: &str = "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, -} +use super::{decrypt, encrypt, windows_focus::set_focus}; /// 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>, -} +pub struct Biometric {} -impl BiometricLockSystem { - pub fn new() -> Self { - Self { - secure_memory: Arc::new(Mutex::new( - crate::secure_memory::dpapi::DpapiSecretKVStore::new(), - )), - } - } -} +impl super::BiometricTrait for Biometric { + // FIXME: Remove unwraps! They panic and terminate the whole application. + #[allow(clippy::unwrap_used)] + async fn prompt(hwnd: Vec, message: String) -> Result { + let h = isize::from_le_bytes(hwnd.clone().try_into().unwrap()); -impl Default for BiometricLockSystem { - fn default() -> Self { - Self::new() - } -} + let h = h as *mut c_void; + let window = HWND(h); -impl super::BiometricTrait for BiometricLockSystem { - async fn authenticate(&self, _hwnd: Vec, message: String) -> Result { - windows_hello_authenticate(message) - } + // 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); - async fn authenticate_available(&self) -> Result { - match UserConsentVerifier::CheckAvailabilityAsync()?.get()? { - UserConsentVerifierAvailability::Available => Ok(true), - UserConsentVerifierAvailability::DeviceBusy => Ok(true), + // 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), } } - async fn unenroll(&self, user_id: &str) -> Result<()> { - self.secure_memory.lock().await.remove(user_id); - delete_keychain_entry(user_id).await - } + async fn available() -> Result { + let ucv_available = UserConsentVerifier::CheckAvailabilityAsync()?.get()?; - 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; CHALLENGE_LENGTH]; - rand::fill(&mut challenge); - - // This key is unique to the challenge - let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge)?; - let (wrapped_key, nonce) = encrypt_data(&windows_hello_key, key)?; - - set_keychain_entry( - user_id, - &WindowsHelloKeychainEntry { - nonce: nonce - .as_slice() - .try_into() - .map_err(|_| anyhow!("Invalid nonce length"))?, - 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 { - set_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())? { - 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)?; - 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) + match ucv_available { + UserConsentVerifierAvailability::Available => Ok(true), + UserConsentVerifierAvailability::DeviceBusy => Ok(true), // TODO: Look into removing this and making the check more ad-hoc + _ => Ok(false), } } - 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)) + fn derive_key_material(challenge_str: Option<&str>) -> Result { + let challenge: [u8; 16] = match challenge_str { + Some(challenge_str) => base64_engine + .decode(challenge_str)? + .try_into() + .map_err(|e: Vec<_>| anyhow!("Expect length {}, got {}", 16, e.len()))?, + None => random_challenge(), + }; + + // Uses a key derived from the iv. This key is not intended to add any security + // but only a place-holder + let key = Sha256::digest(challenge); + let key_b64 = base64_engine.encode(key); + let iv_b64 = base64_engine.encode(challenge); + Ok(OsDerivedKey { key_b64, iv_b64 }) } - async fn has_persistent(&self, user_id: &str) -> Result { - Ok(get_keychain_entry(user_id).await.is_ok()) + async fn set_biometric_secret( + service: &str, + account: &str, + secret: &str, + key_material: Option, + iv_b64: &str, + ) -> Result { + let key_material = key_material.ok_or(anyhow!( + "Key material is required for Windows Hello protected keys" + ))?; + + let encrypted_secret = encrypt(secret, &key_material, iv_b64)?; + crate::password::set_password(service, account, &encrypted_secret).await?; + Ok(encrypted_secret) } -} -/// Get a yes/no authorization without any cryptographic backing. -/// This API has better focusing behavior -fn windows_hello_authenticate(message: String) -> Result { - println!( - "[Windows Hello] Authenticating to perform UV with message: {}", - message - ); - // 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() }; + async fn get_biometric_secret( + service: &str, + account: &str, + key_material: Option, + ) -> Result { + let key_material = key_material.ok_or(anyhow!( + "Key material is required for Windows Hello protected keys" + ))?; - let userconsent_verifier = factory::()?; - let userconsent_result: IAsyncOperation = unsafe { - userconsent_verifier - .RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))? - }; - - match userconsent_result.get()? { - 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; CHALLENGE_LENGTH], -) -> Result<[u8; XCHACHA20POLY1305_KEY_LENGTH]> { - println!( - "[Windows Hello] Authenticating to sign challenge: {:?}", - 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 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 Biometrics signing key - let credential = { - let key_credential_creation_result = KeyCredentialManager::RequestCreateAsync( - h!("BitwardenBiometricsV2"), - KeyCredentialCreationOption::FailIfExists, - )? - .get()?; - match key_credential_creation_result.Status()? { - KeyCredentialStatus::CredentialAlreadyExists => { - KeyCredentialManager::OpenAsync(h!("BitwardenBiometricsV2"))?.get()? + let encrypted_secret = crate::password::get_password(service, account).await?; + match CipherString::from_str(&encrypted_secret) { + Ok(secret) => { + // If the secret is a CipherString, it is encrypted and we need to decrypt it. + let secret = decrypt(&secret, &key_material)?; + Ok(secret) + } + Err(_) => { + // If the secret is not a CipherString, it is not encrypted and we can return it + // directly. + Ok(encrypted_secret) } - KeyCredentialStatus::Success => key_credential_creation_result, - _ => return Err(anyhow!("Failed to create key credential")), } } - .Credential()?; - - let signature = credential - .RequestSignAsync(&CryptographicBuffer::CreateFromByteArray( - challenge.as_slice(), - )?)? - .get()?; - if signature.Status()? != KeyCredentialStatus::Success { - return Err(anyhow!("Failed to sign data")); - } - - let signature_buffer = signature.Result()?; - let mut signature_value = windows::core::Array::::with_len( - signature_buffer.Length().map_err(|e| anyhow!(e))? 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. - let windows_hello_key = Sha256::digest(signature_value.as_slice()).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 -} - -async fn has_keychain_entry(user_id: &str) -> Result { - Ok(!password::get_password(KEYCHAIN_SERVICE_NAME, user_id) - .await? - .is_empty()) -} - -/// 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) +fn random_challenge() -> [u8; 16] { + let mut challenge = [0u8; 16]; + rand::rng().fill_bytes(&mut challenge); + challenge } #[cfg(test)] mod tests { - use crate::biometric::{ - biometric::{ - decrypt_data, encrypt_data, windows_hello_authenticate, - windows_hello_authenticate_with_crypto, CHALLENGE_LENGTH, XCHACHA20POLY1305_KEY_LENGTH, - }, - BiometricLockSystem, BiometricTrait, - }; + use super::*; + + use crate::biometric::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); - } - - // Note: These tests are ignored because they require manual intervention to run - - #[test] - #[ignore] - fn test_windows_hello_authenticate_with_crypto_manual() { - let challenge = [0u8; CHALLENGE_LENGTH]; - let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge); - println!( - "Windows hello key {:?} for challenge {:?}", - windows_hello_key, challenge - ); + fn test_derive_key_material() { + let iv_input = "l9fhDUP/wDJcKwmEzcb/3w=="; + let result = ::derive_key_material(Some(iv_input)).unwrap(); + let key = base64_engine.decode(result.key_b64).unwrap(); + assert_eq!(key.len(), 32); + assert_eq!(result.iv_b64, iv_input) } #[test] - #[ignore] - fn test_windows_hello_authenticate() { - let authenticated = - windows_hello_authenticate("Test Windows Hello authentication".to_string()); - println!("Windows Hello authentication result: {:?}", authenticated); + fn test_derive_key_material_no_iv() { + let result = ::derive_key_material(None).unwrap(); + let key = base64_engine.decode(result.key_b64).unwrap(); + assert_eq!(key.len(), 32); + let iv = base64_engine.decode(result.iv_b64).unwrap(); + assert_eq!(iv.len(), 16); } #[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); + #[cfg(feature = "manual_test")] + async fn test_prompt() { + ::prompt( + vec![0, 0, 0, 0, 0, 0, 0, 0], + String::from("Hello from Rust"), + ) + .await + .unwrap(); + } - let windows_hello_lock_system = BiometricLockSystem::new(); + #[tokio::test] + #[cfg(feature = "manual_test")] + async fn test_available() { + assert!(::available().await.unwrap()) + } - println!("Enrolling user"); - windows_hello_lock_system - .enroll_persistent(user_id, &key) + #[tokio::test] + #[cfg(feature = "manual_test")] + async fn get_biometric_secret_requires_key() { + let result = ::get_biometric_secret("", "", None).await; + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Key material is required for Windows Hello protected keys" + ); + } + + #[tokio::test] + #[cfg(feature = "manual_test")] + async fn get_biometric_secret_handles_unencrypted_secret() { + let test = "test"; + let secret = "password"; + let key_material = KeyMaterial { + os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), + client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()), + }; + crate::password::set_password(test, test, secret) .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()) + let result = + ::get_biometric_secret(test, test, Some(key_material)) + .await + .unwrap(); + crate::password::delete_password("test", "test") .await .unwrap(); - assert_eq!(key_after_unlock, key); + assert_eq!(result, secret); + } - println!("Unenrolling user"); - windows_hello_lock_system.unenroll(user_id).await.unwrap(); - assert!(!windows_hello_lock_system - .has_persistent(user_id) + #[tokio::test] + #[cfg(feature = "manual_test")] + async fn get_biometric_secret_handles_encrypted_secret() { + let test = "test"; + let secret = + CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt + let key_material = KeyMaterial { + os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), + client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()), + }; + crate::password::set_password(test, test, &secret.to_string()) .await - .unwrap()); + .unwrap(); + + let result = + ::get_biometric_secret(test, test, Some(key_material)) + .await + .unwrap(); + crate::password::delete_password("test", "test") + .await + .unwrap(); + assert_eq!(result, "secret"); + } + + #[tokio::test] + async fn set_biometric_secret_requires_key() { + let result = + ::set_biometric_secret("", "", "", None, "").await; + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Key material is required for Windows Hello protected keys" + ); } } diff --git a/apps/desktop/desktop_native/core/src/biometric/windows_focus.rs b/apps/desktop/desktop_native/core/src/biometric/windows_focus.rs index 9aca4565b57..ce51f82862d 100644 --- a/apps/desktop/desktop_native/core/src/biometric/windows_focus.rs +++ b/apps/desktop/desktop_native/core/src/biometric/windows_focus.rs @@ -2,91 +2,27 @@ 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, - }, + Input::KeyboardAndMouse::SetFocus, + WindowsAndMessaging::{FindWindowA, SetForegroundWindow}, }, }, }; -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 { + let class_name = s!("Credential Dialog Xaml Host"); + let hwnd = unsafe { FindWindowA(class_name, None) }; + if let Ok(hwnd) = hwnd { set_focus(hwnd); } } -/// Sets focus to a window using a few unstable methods -pub(crate) fn set_focus(hwnd: 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. - - // 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 foregroud, 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); + let _ = SetForegroundWindow(window); + let _ = SetFocus(Some(window)); } } diff --git a/apps/desktop/desktop_native/core/src/biometric/linux.rs b/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs similarity index 100% rename from apps/desktop/desktop_native/core/src/biometric/linux.rs rename to apps/desktop/desktop_native/core/src/biometric_v2/linux.rs 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..5775595ec26 --- /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 = "linux.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 { + /// 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 the 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/unimplemented.rs b/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs similarity index 100% rename from apps/desktop/desktop_native/core/src/biometric/unimplemented.rs rename to apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs 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..881075ef29f --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs @@ -0,0 +1,412 @@ +//! 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 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::{ + 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"; +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) + } + + async fn authenticate_available(&self) -> Result { + match UserConsentVerifier::CheckAvailabilityAsync()?.get()? { + UserConsentVerifierAvailability::Available => Ok(true), + 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 mut challenge = [0u8; CHALLENGE_LENGTH]; + rand::fill(&mut challenge); + + // This key is unique to the challenge + let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge)?; + let (wrapped_key, nonce) = encrypt_data(&windows_hello_key, key)?; + + set_keychain_entry( + user_id, + &WindowsHelloKeychainEntry { + nonce: nonce + .as_slice() + .try_into() + .map_err(|_| anyhow!("Invalid nonce length"))?, + 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 { + set_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())? { + 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)?; + 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 +fn windows_hello_authenticate(message: String) -> Result { + println!( + "[Windows Hello] Authenticating to perform UV with message: {}", + message + ); + // 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 userconsent_verifier = factory::()?; + let userconsent_result: IAsyncOperation = unsafe { + userconsent_verifier + .RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))? + }; + + match userconsent_result.get()? { + 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; CHALLENGE_LENGTH], +) -> Result<[u8; XCHACHA20POLY1305_KEY_LENGTH]> { + println!( + "[Windows Hello] Authenticating to sign challenge: {:?}", + 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 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 Biometrics signing key + let credential = { + let key_credential_creation_result = KeyCredentialManager::RequestCreateAsync( + h!("BitwardenBiometricsV2"), + KeyCredentialCreationOption::FailIfExists, + )? + .get()?; + match key_credential_creation_result.Status()? { + KeyCredentialStatus::CredentialAlreadyExists => { + KeyCredentialManager::OpenAsync(h!("BitwardenBiometricsV2"))?.get()? + } + KeyCredentialStatus::Success => key_credential_creation_result, + _ => return Err(anyhow!("Failed to create key credential")), + } + } + .Credential()?; + + let signature = credential + .RequestSignAsync(&CryptographicBuffer::CreateFromByteArray( + challenge.as_slice(), + )?)? + .get()?; + if signature.Status()? != KeyCredentialStatus::Success { + return Err(anyhow!("Failed to sign data")); + } + + let signature_buffer = signature.Result()?; + let mut signature_value = windows::core::Array::::with_len( + signature_buffer.Length().map_err(|e| anyhow!(e))? 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. + let windows_hello_key = Sha256::digest(signature_value.as_slice()).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 +} + +async fn has_keychain_entry(user_id: &str) -> Result { + Ok(!password::get_password(KEYCHAIN_SERVICE_NAME, user_id) + .await? + .is_empty()) +} + +/// 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) +} + +#[cfg(test)] +mod tests { + use crate::biometric::{ + biometric::{ + decrypt_data, encrypt_data, 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); + } + + // Note: These tests are ignored because they require manual intervention to run + + #[test] + #[ignore] + fn test_windows_hello_authenticate_with_crypto_manual() { + let challenge = [0u8; CHALLENGE_LENGTH]; + let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge); + println!( + "Windows hello key {:?} for challenge {:?}", + windows_hello_key, challenge + ); + } + + #[test] + #[ignore] + fn test_windows_hello_authenticate() { + let authenticated = + windows_hello_authenticate("Test Windows Hello authentication".to_string()); + println!("Windows Hello authentication result: {:?}", authenticated); + } + + #[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 00000000000..9aca4565b57 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs @@ -0,0 +1,92 @@ +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 +pub(crate) 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 foregroud, 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); + } +} diff --git a/apps/desktop/desktop_native/core/src/crypto/cipher_string.rs b/apps/desktop/desktop_native/core/src/crypto/cipher_string.rs new file mode 100644 index 00000000000..81f734bf0f2 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/crypto/cipher_string.rs @@ -0,0 +1,212 @@ +use std::{fmt::Display, str::FromStr}; + +use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; + +use crate::error::{CSParseError, Error}; + +#[allow(unused, non_camel_case_types)] +pub enum CipherString { + // 0 + AesCbc256_B64 { + iv: [u8; 16], + data: Vec, + }, + // 1 + AesCbc128_HmacSha256_B64 { + iv: [u8; 16], + mac: [u8; 32], + data: Vec, + }, + // 2 + AesCbc256_HmacSha256_B64 { + iv: [u8; 16], + mac: [u8; 32], + data: Vec, + }, + // 3 + Rsa2048_OaepSha256_B64 { + data: Vec, + }, + // 4 + Rsa2048_OaepSha1_B64 { + data: Vec, + }, + // 5 + Rsa2048_OaepSha256_HmacSha256_B64 { + mac: [u8; 32], + data: Vec, + }, + // 6 + Rsa2048_OaepSha1_HmacSha256_B64 { + mac: [u8; 32], + data: Vec, + }, +} + +// We manually implement these to make sure we don't print any sensitive data +impl std::fmt::Debug for CipherString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CipherString") + .field("type", &self.enc_type_name()) + .finish() + } +} + +fn invalid_len_error(expected: usize) -> impl Fn(Vec) -> CSParseError { + move |e: Vec<_>| CSParseError::InvalidBase64Length { + expected, + got: e.len(), + } +} + +impl FromStr for CipherString { + type Err = Error; + + fn from_str(s: &str) -> Result { + let (enc_type, data) = s.split_once('.').ok_or(CSParseError::NoType)?; + + let parts: Vec<_> = data.split('|').collect(); + match (enc_type, parts.len()) { + ("0", 2) => { + let iv_str = parts[0]; + let data_str = parts[1]; + + let iv = base64_engine + .decode(iv_str) + .map_err(CSParseError::InvalidBase64)? + .try_into() + .map_err(invalid_len_error(16))?; + + let data = base64_engine + .decode(data_str) + .map_err(CSParseError::InvalidBase64)?; + + Ok(CipherString::AesCbc256_B64 { iv, data }) + } + + ("1" | "2", 3) => { + let iv_str = parts[0]; + let data_str = parts[1]; + let mac_str = parts[2]; + + let iv = base64_engine + .decode(iv_str) + .map_err(CSParseError::InvalidBase64)? + .try_into() + .map_err(invalid_len_error(16))?; + + let mac = base64_engine + .decode(mac_str) + .map_err(CSParseError::InvalidBase64)? + .try_into() + .map_err(invalid_len_error(32))?; + + let data = base64_engine + .decode(data_str) + .map_err(CSParseError::InvalidBase64)?; + + if enc_type == "1" { + Ok(CipherString::AesCbc128_HmacSha256_B64 { iv, mac, data }) + } else { + Ok(CipherString::AesCbc256_HmacSha256_B64 { iv, mac, data }) + } + } + + ("3" | "4", 1) => { + let data = base64_engine + .decode(data) + .map_err(CSParseError::InvalidBase64)?; + if enc_type == "3" { + Ok(CipherString::Rsa2048_OaepSha256_B64 { data }) + } else { + Ok(CipherString::Rsa2048_OaepSha1_B64 { data }) + } + } + ("5" | "6", 2) => { + unimplemented!() + } + + (enc_type, parts) => Err(CSParseError::InvalidType { + enc_type: enc_type.to_string(), + parts, + } + .into()), + } + } +} + +impl Display for CipherString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.", self.enc_type())?; + + let mut parts = Vec::<&[u8]>::new(); + + match self { + CipherString::AesCbc256_B64 { iv, data } => { + parts.push(iv); + parts.push(data); + } + CipherString::AesCbc128_HmacSha256_B64 { iv, mac, data } => { + parts.push(iv); + parts.push(data); + parts.push(mac); + } + CipherString::AesCbc256_HmacSha256_B64 { iv, mac, data } => { + parts.push(iv); + parts.push(data); + parts.push(mac); + } + CipherString::Rsa2048_OaepSha256_B64 { data } => { + parts.push(data); + } + CipherString::Rsa2048_OaepSha1_B64 { data } => { + parts.push(data); + } + CipherString::Rsa2048_OaepSha256_HmacSha256_B64 { mac, data } => { + parts.push(data); + parts.push(mac); + } + CipherString::Rsa2048_OaepSha1_HmacSha256_B64 { mac, data } => { + parts.push(data); + parts.push(mac); + } + } + + for i in 0..parts.len() { + if i == parts.len() - 1 { + write!(f, "{}", base64_engine.encode(parts[i]))?; + } else { + write!(f, "{}|", base64_engine.encode(parts[i]))?; + } + } + + Ok(()) + } +} + +impl CipherString { + fn enc_type(&self) -> u8 { + match self { + CipherString::AesCbc256_B64 { .. } => 0, + CipherString::AesCbc128_HmacSha256_B64 { .. } => 1, + CipherString::AesCbc256_HmacSha256_B64 { .. } => 2, + CipherString::Rsa2048_OaepSha256_B64 { .. } => 3, + CipherString::Rsa2048_OaepSha1_B64 { .. } => 4, + CipherString::Rsa2048_OaepSha256_HmacSha256_B64 { .. } => 5, + CipherString::Rsa2048_OaepSha1_HmacSha256_B64 { .. } => 6, + } + } + + fn enc_type_name(&self) -> &str { + match self.enc_type() { + 0 => "AesCbc256_B64", + 1 => "AesCbc128_HmacSha256_B64", + 2 => "AesCbc256_HmacSha256_B64", + 3 => "Rsa2048_OaepSha256_B64", + 4 => "Rsa2048_OaepSha1_B64", + 5 => "Rsa2048_OaepSha256_HmacSha256_B64", + 6 => "Rsa2048_OaepSha1_HmacSha256_B64", + _ => "Unknown", + } + } +} diff --git a/apps/desktop/desktop_native/core/src/crypto/crypto.rs b/apps/desktop/desktop_native/core/src/crypto/crypto.rs new file mode 100644 index 00000000000..d9e2aec3046 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/crypto/crypto.rs @@ -0,0 +1,35 @@ +//! Cryptographic primitives used in the SDK + +use aes::cipher::{ + block_padding::Pkcs7, generic_array::GenericArray, typenum::U32, BlockDecryptMut, + BlockEncryptMut, KeyIvInit, +}; + +use crate::error::{CryptoError, Result}; + +use super::CipherString; + +pub fn decrypt_aes256(iv: &[u8; 16], data: &[u8], key: GenericArray) -> Result> { + let iv = GenericArray::from_slice(iv); + let mut data = data.to_vec(); + let decrypted_key_slice = cbc::Decryptor::::new(&key, iv) + .decrypt_padded_mut::(&mut data) + .map_err(|_| CryptoError::KeyDecrypt)?; + + // Data is decrypted in place and returns a subslice of the original Vec, to avoid cloning it, we truncate to the subslice length + let decrypted_len = decrypted_key_slice.len(); + data.truncate(decrypted_len); + + Ok(data) +} + +pub fn encrypt_aes256( + data_dec: &[u8], + iv: [u8; 16], + key: GenericArray, +) -> Result { + let data = cbc::Encryptor::::new(&key, &iv.into()) + .encrypt_padded_vec_mut::(data_dec); + + Ok(CipherString::AesCbc256_B64 { iv, data }) +} diff --git a/apps/desktop/desktop_native/core/src/crypto/mod.rs b/apps/desktop/desktop_native/core/src/crypto/mod.rs new file mode 100644 index 00000000000..91e1dc4a4d4 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/crypto/mod.rs @@ -0,0 +1,8 @@ +//! Please delete this module after deleting biometric v1. + +pub use cipher_string::*; +pub use crypto::*; + +mod cipher_string; +#[allow(clippy::module_inception)] +mod crypto; diff --git a/apps/desktop/desktop_native/core/src/lib.rs b/apps/desktop/desktop_native/core/src/lib.rs index 2fa263dafb9..1519ee651dc 100644 --- a/apps/desktop/desktop_native/core/src/lib.rs +++ b/apps/desktop/desktop_native/core/src/lib.rs @@ -1,6 +1,8 @@ pub mod autofill; pub mod autostart; pub mod biometric; +pub mod biometric_v2; +pub(crate) mod crypto; pub mod clipboard; pub mod error; pub mod ipc; diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 3951341efc4..2f88d5b841d 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -22,6 +22,34 @@ export declare namespace passwords { export function isAvailable(): Promise } export declare namespace biometrics { + export function prompt(hwnd: Buffer, message: string): Promise + export function available(): Promise + export function setBiometricSecret(service: string, account: string, secret: string, keyMaterial: KeyMaterial | undefined | null, ivB64: string): Promise + /** + * Retrieves the biometric secret for the given service and account. + * Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist. + */ + export function getBiometricSecret(service: string, account: string, keyMaterial?: KeyMaterial | undefined | null): Promise + /** + * Derives key material from biometric data. Returns a string encoded with a + * base64 encoded key and the base64 encoded challenge used to create it + * separated by a `|` character. + * + * If the iv is provided, it will be used as the challenge. Otherwise a random challenge will be generated. + * + * `format!("|")` + */ + export function deriveKeyMaterial(iv?: string | undefined | null): Promise + export interface KeyMaterial { + osKeyPartB64: string + clientKeyPartB64?: string + } + export interface OsDerivedKey { + keyB64: string + 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 @@ -212,8 +240,8 @@ export declare namespace chromium_importer { login?: Login failure?: LoginImportFailure } - export function getInstalledBrowsers(): Promise> - export function getAvailableProfiles(browser: string): Promise> + export function getInstalledBrowsers(): Array + export function getAvailableProfiles(browser: string): Array export function importLogins(browser: string, profileId: string): Promise> } export declare namespace autotype { diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index bb76ac86846..6377246b34b 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -50,18 +50,119 @@ pub mod passwords { } #[napi] +#[deprecated(note = "Use biometrics v2")] pub mod biometrics { - use desktop_core::biometric::BiometricTrait; + use desktop_core::biometric::{Biometric, BiometricTrait}; + + // Prompt for biometric confirmation + #[napi] + pub async fn prompt( + hwnd: napi::bindgen_prelude::Buffer, + message: String, + ) -> napi::Result { + Biometric::prompt(hwnd.into(), message) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn available() -> napi::Result { + Biometric::available() + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn set_biometric_secret( + service: String, + account: String, + secret: String, + key_material: Option, + iv_b64: String, + ) -> napi::Result { + Biometric::set_biometric_secret( + &service, + &account, + &secret, + key_material.map(|m| m.into()), + &iv_b64, + ) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + /// Retrieves the biometric secret for the given service and account. + /// Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist. + #[napi] + pub async fn get_biometric_secret( + service: String, + account: String, + key_material: Option, + ) -> napi::Result { + Biometric::get_biometric_secret(&service, &account, key_material.map(|m| m.into())) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + /// Derives key material from biometric data. Returns a string encoded with a + /// base64 encoded key and the base64 encoded challenge used to create it + /// separated by a `|` character. + /// + /// If the iv is provided, it will be used as the challenge. Otherwise a random challenge will be generated. + /// + /// `format!("|")` + #[allow(clippy::unused_async)] // FIXME: Remove unused async! + #[napi] + pub async fn derive_key_material(iv: Option) -> napi::Result { + Biometric::derive_key_material(iv.as_deref()) + .map(|k| k.into()) + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi(object)] + pub struct KeyMaterial { + pub os_key_part_b64: String, + pub client_key_part_b64: Option, + } + + impl From for desktop_core::biometric::KeyMaterial { + fn from(km: KeyMaterial) -> Self { + desktop_core::biometric::KeyMaterial { + os_key_part_b64: km.os_key_part_b64, + client_key_part_b64: km.client_key_part_b64, + } + } + } + + #[napi(object)] + pub struct OsDerivedKey { + pub key_b64: String, + pub iv_b64: String, + } + + impl From for OsDerivedKey { + fn from(km: desktop_core::biometric::OsDerivedKey) -> Self { + OsDerivedKey { + key_b64: km.key_b64, + iv_b64: km.iv_b64, + } + } + } +} + +#[napi] +pub mod biometrics_v2 { + use desktop_core::biometric_v2::BiometricTrait; #[napi] pub struct BiometricLockSystem { - inner: desktop_core::biometric::BiometricLockSystem, + inner: desktop_core::biometric_v2::BiometricLockSystem, } #[napi] pub fn init_biometric_system() -> napi::Result { Ok(BiometricLockSystem { - inner: desktop_core::biometric::BiometricLockSystem::new(), + inner: desktop_core::biometric_v2::BiometricLockSystem::new(), }) } 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 e3be5b289d0..c725160964b 100644 --- a/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts @@ -15,4 +15,6 @@ export abstract class DesktopBiometricsService extends BiometricsService { 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 enableV2BiometricsBackend(): 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 91f61c2e7c1..ce2e4807536 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 @@ -58,6 +58,8 @@ export class MainBiometricsIPCListener { message.userId as UserId, SymmetricCryptoKey.fromString(message.key as string), ); + case BiometricAction.EnableV2: + return await this.biometricService.enableV2BiometricsBackend(); default: return; } 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 4c820fd67da..21c11a01b5a 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts @@ -8,7 +8,9 @@ import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-manageme import { WindowMain } from "../../main/window.main"; import { DesktopBiometricsService } from "./desktop.biometrics.service"; -import { OsBiometricService } from "./os-biometrics.service"; +import { OsBiometricService } from "./native-v2/os-biometrics.service"; + +import { LinuxBiometricsSystem, MacBiometricsSystem, WindowsBiometricsSystem } from "./native-v2"; export class MainBiometricsService extends DesktopBiometricsService { private osBiometricsService: OsBiometricService; @@ -18,27 +20,49 @@ export class MainBiometricsService extends DesktopBiometricsService { private i18nService: I18nService, private windowMain: WindowMain, private logService: LogService, - platform: NodeJS.Platform, + private platform: NodeJS.Platform, private biometricStateService: BiometricStateService, ) { super(); - if (platform === "win32") { + this.loadNativeBiometricsModuleV1(); + } + + /** + * @deprecated + */ + private loadNativeBiometricsModuleV1() { + this.logService.info("[BiometricsMain] Loading native biometrics module v1"); + if (this.platform === "win32") { // eslint-disable-next-line - const OsBiometricsServiceWindows = require("./os-biometrics-windows.service").default; + const OsBiometricsServiceWindows = + require("./native-v1/os-biometrics-windows.service").default; this.osBiometricsService = new OsBiometricsServiceWindows(this.i18nService, this.windowMain); - } else if (platform === "darwin") { + } else if (this.platform === "darwin") { // eslint-disable-next-line - const OsBiometricsServiceMac = require("./os-biometrics-mac.service").default; + const OsBiometricsServiceMac = require("./native-v1/os-biometrics-mac.service").default; this.osBiometricsService = new OsBiometricsServiceMac(this.i18nService, this.logService); - } else if (platform === "linux") { + } else if (this.platform === "linux") { // eslint-disable-next-line - const OsBiometricsServiceLinux = require("./os-biometrics-linux.service").default; + const OsBiometricsServiceLinux = require("./native-v1/os-biometrics-linux.service").default; this.osBiometricsService = new OsBiometricsServiceLinux(); } else { throw new Error("Unsupported platform"); } } + private loadNativeBiometricsModuleV2() { + this.logService.info("[BiometricsMain] Loading native biometrics module v2"); + if (this.platform === "win32") { + this.osBiometricsService = new WindowsBiometricsSystem(this.i18nService, this.windowMain); + } else if (this.platform === "darwin") { + this.osBiometricsService = new MacBiometricsSystem(this.i18nService, this.logService); + } else if (this.platform === "linux") { + this.osBiometricsService = new LinuxBiometricsSystem(); + } else { + throw new Error("Unsupported platform"); + } + } + /** * Get the status of biometrics for the platform. Biometrics status for the platform can be one of: * - Available: Biometrics are available and can be used (On windows hello, (touch id (for now)) and polkit, this MAY fall back to password) @@ -136,4 +160,8 @@ export class MainBiometricsService extends DesktopBiometricsService { async hasPersistentKey(userId: UserId): Promise { return await this.osBiometricsService.hasPersistentKey(userId); } + + async enableV2BiometricsBackend(): Promise { + this.loadNativeBiometricsModuleV2(); + } } diff --git a/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-linux.service.spec.ts b/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-linux.service.spec.ts new file mode 100644 index 00000000000..b0847ec692b --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-linux.service.spec.ts @@ -0,0 +1,86 @@ +import { mock } from "jest-mock-extended"; + +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { passwords } from "@bitwarden/desktop-napi"; +import { BiometricStateService } from "@bitwarden/key-management"; + +import OsBiometricsServiceLinux from "../os-biometrics-linux.service"; + +jest.mock("@bitwarden/desktop-napi", () => ({ + biometrics: { + setBiometricSecret: jest.fn(), + getBiometricSecret: jest.fn(), + deleteBiometricSecret: jest.fn(), + prompt: jest.fn(), + available: jest.fn(), + deriveKeyMaterial: jest.fn(), + }, + passwords: { + deletePassword: jest.fn(), + getPassword: jest.fn(), + isAvailable: jest.fn(), + PASSWORD_NOT_FOUND: "Password not found", + }, +})); + +describe("OsBiometricsServiceLinux", () => { + let service: OsBiometricsServiceLinux; + let logService: LogService; + + const mockUserId = "test-user-id" as UserId; + + beforeEach(() => { + const biometricStateService = mock(); + const encryptService = mock(); + const cryptoFunctionService = mock(); + logService = mock(); + service = new OsBiometricsServiceLinux( + biometricStateService, + encryptService, + cryptoFunctionService, + logService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("deleteBiometricKey", () => { + const serviceName = "Bitwarden_biometric"; + const keyName = "test-user-id_user_biometric"; + + it("should delete biometric key successfully", async () => { + await service.deleteBiometricKey(mockUserId); + + expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); + }); + + it("should not throw error if key not found", async () => { + passwords.deletePassword = jest + .fn() + .mockRejectedValueOnce(new Error(passwords.PASSWORD_NOT_FOUND)); + + await service.deleteBiometricKey(mockUserId); + + expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); + expect(logService.debug).toHaveBeenCalledWith( + "[OsBiometricService] Biometric key %s not found for service %s.", + keyName, + serviceName, + ); + }); + + it("should throw error for unexpected errors", async () => { + const error = new Error("Unexpected error"); + passwords.deletePassword = jest.fn().mockRejectedValueOnce(error); + + await expect(service.deleteBiometricKey(mockUserId)).rejects.toThrow(error); + + expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); + }); + }); +}); diff --git a/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-linux.service.ts b/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-linux.service.ts new file mode 100644 index 00000000000..7a5d8e9ec08 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-linux.service.ts @@ -0,0 +1,236 @@ +import { spawn } from "child_process"; + +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 { 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 { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management"; + +import { isFlatpak, isLinux, isSnapStore } from "../../../utils"; +import { OsBiometricService } from "../native-v2/os-biometrics.service"; + +const polkitPolicy = ` + + + + + Unlock Bitwarden + Authenticate to unlock Bitwarden + + no + no + auth_self + + +`; +const policyFileName = "com.bitwarden.Bitwarden.policy"; +const policyPath = "/usr/share/polkit-1/actions/"; + +const SERVICE = "Bitwarden_biometric"; + +function getLookupKeyForUser(userId: UserId): string { + return `${userId}_user_biometric`; +} + +export default class OsBiometricsServiceLinux implements OsBiometricService { + constructor( + private biometricStateService: BiometricStateService, + private encryptService: EncryptService, + private cryptoFunctionService: CryptoFunctionService, + 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; + private clientKeyHalves = new Map(); + + async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { + const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); + + const storageDetails = await this.getStorageDetails({ + clientKeyHalfB64: clientKeyHalf ? Utils.fromBufferToB64(clientKeyHalf) : undefined, + }); + await biometrics.setBiometricSecret( + SERVICE, + getLookupKeyForUser(userId), + key.toBase64(), + storageDetails.key_material, + storageDetails.ivB64, + ); + } + + async deleteBiometricKey(userId: UserId): Promise { + try { + await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId)); + } catch (e) { + if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) { + this.logService.debug( + "[OsBiometricService] Biometric key %s not found for service %s.", + getLookupKeyForUser(userId), + SERVICE, + ); + } else { + throw e; + } + } + } + + async getBiometricKey(userId: UserId): Promise { + const success = await this.authenticateBiometric(); + + if (!success) { + throw new Error("Biometric authentication failed"); + } + + const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId)); + + if (value == null || value == "") { + return null; + } else { + let clientKeyPartB64: string | null = null; + if (this.clientKeyHalves.has(userId)) { + clientKeyPartB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)!); + } + const encValue = new EncString(value); + this.setIv(encValue.iv); + const storageDetails = await this.getStorageDetails({ + clientKeyHalfB64: clientKeyPartB64 ?? undefined, + }); + const storedValue = await biometrics.getBiometricSecret( + SERVICE, + getLookupKeyForUser(userId), + storageDetails.key_material, + ); + return SymmetricCryptoKey.fromString(storedValue); + } + } + + async authenticateBiometric(): Promise { + const hwnd = Buffer.from(""); + return await biometrics.prompt(hwnd, ""); + } + + async supportsBiometrics(): Promise { + // We assume all linux distros have some polkit implementation + // that either has bitwarden set up or not, which is reflected in osBiomtricsNeedsSetup. + // Snap does not have access at the moment to polkit + // This could be dynamically detected on dbus in the future. + // We should check if a libsecret implementation is available on the system + // because otherwise we cannot offlod the protected userkey to secure storage. + return await passwords.isAvailable(); + } + + async needsSetup(): Promise { + if (isSnapStore()) { + return false; + } + + // check whether the polkit policy is loaded via dbus call to polkit + return !(await biometrics.available()); + } + + async canAutoSetup(): Promise { + // We cannot auto setup on snap or flatpak since the filesystem is sandboxed. + // The user needs to manually set up the polkit policy outside of the sandbox + // since we allow access to polkit via dbus for the sandboxed clients, the authentication works from + // the sandbox, once the policy is set up outside of the sandbox. + return isLinux() && !isSnapStore() && !isFlatpak(); + } + + async runSetup(): Promise { + const process = spawn("pkexec", [ + "bash", + "-c", + `echo '${polkitPolicy}' > ${policyPath + policyFileName} && chown root:root ${policyPath + policyFileName} && chcon system_u:object_r:usr_t:s0 ${policyPath + policyFileName}`, + ]); + + await new Promise((resolve, reject) => { + process.on("close", (code) => { + if (code !== 0) { + reject("Failed to set up polkit policy"); + } else { + resolve(null); + } + }); + }); + } + + // Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey + // when we want to force a re-derive of the key material. + private setIv(iv?: string) { + this._iv = iv ?? null; + this._osKeyHalf = null; + } + + private async getStorageDetails({ + clientKeyHalfB64, + }: { + clientKeyHalfB64: string | undefined; + }): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> { + if (this._osKeyHalf == null) { + const keyMaterial = await biometrics.deriveKeyMaterial(this._iv); + this._osKeyHalf = keyMaterial.keyB64; + this._iv = keyMaterial.ivB64; + } + + if (this._iv == null) { + throw new Error("Initialization Vector is null"); + } + + return { + key_material: { + osKeyPartB64: this._osKeyHalf, + clientKeyPartB64: clientKeyHalfB64, + }, + ivB64: this._iv, + }; + } + + private async getOrCreateBiometricEncryptionClientKeyHalf( + userId: UserId, + key: SymmetricCryptoKey, + ): Promise { + if (this.clientKeyHalves.has(userId)) { + return this.clientKeyHalves.get(userId) || null; + } + + // Retrieve existing key half if it exists + let clientKeyHalf: Uint8Array | null = null; + const encryptedClientKeyHalf = + await this.biometricStateService.getEncryptedClientKeyHalf(userId); + if (encryptedClientKeyHalf != null) { + clientKeyHalf = await this.encryptService.decryptBytes(encryptedClientKeyHalf, key); + } + if (clientKeyHalf == null) { + // Set a key half if it doesn't exist + clientKeyHalf = await this.cryptoFunctionService.randomBytes(32); + const encKey = await this.encryptService.encryptBytes(clientKeyHalf, key); + await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId); + } + + this.clientKeyHalves.set(userId, clientKeyHalf); + + return clientKeyHalf; + } + + async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { + if (this.clientKeyHalves.has(userId)) { + return BiometricsStatus.Available; + } else { + return BiometricsStatus.UnlockNeeded; + } + } +} diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.spec.ts b/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-mac.service.spec.ts similarity index 100% rename from apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.spec.ts rename to apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-mac.service.spec.ts diff --git a/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-mac.service.ts b/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-mac.service.ts new file mode 100644 index 00000000000..75c480b991e --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-mac.service.ts @@ -0,0 +1,109 @@ +import { systemPreferences } from "electron"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +import { passwords } from "@bitwarden/desktop-napi"; +import { BiometricsStatus } from "@bitwarden/key-management"; + +import { OsBiometricService } from "../native-v2/os-biometrics.service"; + +const SERVICE = "Bitwarden_biometric"; +function getLookupKeyForUser(userId: UserId): string { + return `${userId}_user_biometric`; +} + +export default class OsBiometricsServiceMac implements OsBiometricService { + constructor( + private i18nservice: I18nService, + private logService: LogService, + ) {} + + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { + await this.setBiometricKey(userId, key); + } + + async hasPersistentKey(userId: UserId): Promise { + try { + await passwords.getPassword(SERVICE, getLookupKeyForUser(userId)); + return true; + } catch { + return false; + } + } + + async supportsBiometrics(): Promise { + return systemPreferences.canPromptTouchID(); + } + + async authenticateBiometric(): Promise { + try { + await systemPreferences.promptTouchID(this.i18nservice.t("touchIdConsentMessage")); + return true; + } catch { + return false; + } + } + + async getBiometricKey(userId: UserId): Promise { + const success = await this.authenticateBiometric(); + + if (!success) { + throw new Error("Biometric authentication failed"); + } + const keyB64 = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId)); + if (keyB64 == null) { + return null; + } + + return SymmetricCryptoKey.fromString(keyB64); + } + + async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { + if (await this.valueUpToDate(userId, key)) { + return; + } + + return await passwords.setPassword(SERVICE, getLookupKeyForUser(userId), key.toBase64()); + } + + async deleteBiometricKey(user: UserId): Promise { + try { + return await passwords.deletePassword(SERVICE, getLookupKeyForUser(user)); + } catch (e) { + if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) { + this.logService.debug( + "[OsBiometricService] Biometric key %s not found for service %s.", + getLookupKeyForUser(user), + SERVICE, + ); + } else { + throw e; + } + } + } + + private async valueUpToDate(user: UserId, key: SymmetricCryptoKey): Promise { + try { + const existing = await passwords.getPassword(SERVICE, getLookupKeyForUser(user)); + return existing === key.toBase64(); + } catch { + return false; + } + } + + async needsSetup() { + return false; + } + + async canAutoSetup(): Promise { + return false; + } + + async runSetup(): Promise {} + + async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { + return BiometricsStatus.Available; + } +} diff --git a/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-windows.service.spec.ts b/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-windows.service.spec.ts new file mode 100644 index 00000000000..9f8f15763d5 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-windows.service.spec.ts @@ -0,0 +1,378 @@ +import { randomBytes } from "node:crypto"; + +import { BrowserWindow } from "electron"; +import { mock } from "jest-mock-extended"; + +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +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 { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management"; + +import { WindowMain } from "../../../main/window.main"; + +import OsBiometricsServiceWindows from "./os-biometrics-windows.service"; + +import OsDerivedKey = biometrics.OsDerivedKey; + +jest.mock("@bitwarden/desktop-napi", () => { + return { + biometrics: { + available: jest.fn().mockResolvedValue(true), + getBiometricSecret: jest.fn().mockResolvedValue(""), + setBiometricSecret: jest.fn().mockResolvedValue(""), + deleteBiometricSecret: jest.fn(), + deriveKeyMaterial: jest.fn().mockResolvedValue({ + keyB64: "", + ivB64: "", + }), + prompt: jest.fn().mockResolvedValue(true), + }, + passwords: { + getPassword: jest.fn().mockResolvedValue(null), + deletePassword: jest.fn().mockImplementation(() => {}), + isAvailable: jest.fn(), + PASSWORD_NOT_FOUND: "Password not found", + }, + }; +}); + +describe("OsBiometricsServiceWindows", function () { + const i18nService = mock(); + const windowMain = mock(); + const browserWindow = mock(); + const encryptionService: EncryptService = mock(); + const cryptoFunctionService: CryptoFunctionService = mock(); + const biometricStateService: BiometricStateService = mock(); + const logService = mock(); + + let service: OsBiometricsServiceWindows; + + const key = new SymmetricCryptoKey(new Uint8Array(64)); + const userId = "test-user-id" as UserId; + const serviceKey = "Bitwarden_biometric"; + const storageKey = `${userId}_user_biometric`; + + beforeEach(() => { + windowMain.win = browserWindow; + + service = new OsBiometricsServiceWindows( + i18nService, + windowMain, + logService, + biometricStateService, + encryptionService, + cryptoFunctionService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getBiometricsFirstUnlockStatusForUser", () => { + const userId = "test-user-id" as UserId; + it("should return Available when client key half is set", async () => { + (service as any).clientKeyHalves = new Map(); + (service as any).clientKeyHalves.set(userId, new Uint8Array([1, 2, 3, 4])); + const result = await service.getBiometricsFirstUnlockStatusForUser(userId); + expect(result).toBe(BiometricsStatus.Available); + }); + it("should return UnlockNeeded when client key half is not set", async () => { + (service as any).clientKeyHalves = new Map(); + const result = await service.getBiometricsFirstUnlockStatusForUser(userId); + expect(result).toBe(BiometricsStatus.UnlockNeeded); + }); + }); + + describe("getOrCreateBiometricEncryptionClientKeyHalf", () => { + it("should return cached key half if already present", async () => { + const cachedKeyHalf = new Uint8Array([10, 20, 30]); + (service as any).clientKeyHalves.set(userId.toString(), cachedKeyHalf); + const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); + expect(result).toBe(cachedKeyHalf); + }); + + it("should decrypt and return existing encrypted client key half", async () => { + biometricStateService.getEncryptedClientKeyHalf = jest + .fn() + .mockResolvedValue(new Uint8Array([1, 2, 3])); + const decrypted = new Uint8Array([4, 5, 6]); + encryptionService.decryptBytes = jest.fn().mockResolvedValue(decrypted); + + const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); + + expect(biometricStateService.getEncryptedClientKeyHalf).toHaveBeenCalledWith(userId); + expect(encryptionService.decryptBytes).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]), key); + expect(result).toEqual(decrypted); + expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(decrypted); + }); + + it("should generate, encrypt, store, and cache a new key half if none exists", async () => { + biometricStateService.getEncryptedClientKeyHalf = jest.fn().mockResolvedValue(null); + const randomBytes = new Uint8Array([7, 8, 9]); + cryptoFunctionService.randomBytes = jest.fn().mockResolvedValue(randomBytes); + const encrypted = new Uint8Array([10, 11, 12]); + encryptionService.encryptBytes = jest.fn().mockResolvedValue(encrypted); + biometricStateService.setEncryptedClientKeyHalf = jest.fn().mockResolvedValue(undefined); + + const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); + + expect(cryptoFunctionService.randomBytes).toHaveBeenCalledWith(32); + expect(encryptionService.encryptBytes).toHaveBeenCalledWith(randomBytes, key); + expect(biometricStateService.setEncryptedClientKeyHalf).toHaveBeenCalledWith( + encrypted, + userId, + ); + expect(result).toEqual(randomBytes); + expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(randomBytes); + }); + }); + + describe("supportsBiometrics", () => { + it("should return true if biometrics are available", async () => { + biometrics.available = jest.fn().mockResolvedValue(true); + + const result = await service.supportsBiometrics(); + + expect(result).toBe(true); + }); + + it("should return false if biometrics are not available", async () => { + biometrics.available = jest.fn().mockResolvedValue(false); + + const result = await service.supportsBiometrics(); + + expect(result).toBe(false); + }); + }); + + describe("getBiometricKey", () => { + beforeEach(() => { + biometrics.prompt = jest.fn().mockResolvedValue(true); + }); + + it("should return null when unsuccessfully authenticated biometrics", async () => { + biometrics.prompt = jest.fn().mockResolvedValue(false); + + const result = await service.getBiometricKey(userId); + + expect(result).toBeNull(); + }); + + it.each([null, undefined, ""])( + "should throw error when no biometric key is found '%s'", + async (password) => { + passwords.getPassword = jest.fn().mockResolvedValue(password); + + await expect(service.getBiometricKey(userId)).rejects.toThrow( + "Biometric key not found for user", + ); + + expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey); + }, + ); + + it.each([[false], [true]])( + "should return the biometricKey and setBiometricSecret called if password is not encrypted and cached clientKeyHalves is %s", + async (haveClientKeyHalves) => { + const clientKeyHalveBytes = new Uint8Array([1, 2, 3]); + if (haveClientKeyHalves) { + service["clientKeyHalves"].set(userId, clientKeyHalveBytes); + } + const biometricKey = key.toBase64(); + passwords.getPassword = jest.fn().mockResolvedValue(biometricKey); + biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({ + keyB64: "testKeyB64", + ivB64: "testIvB64", + } satisfies OsDerivedKey); + + const result = await service.getBiometricKey(userId); + + expect(result.toBase64()).toBe(biometricKey); + expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey); + expect(biometrics.setBiometricSecret).toHaveBeenCalledWith( + serviceKey, + storageKey, + biometricKey, + { + osKeyPartB64: "testKeyB64", + clientKeyPartB64: haveClientKeyHalves + ? Utils.fromBufferToB64(clientKeyHalveBytes) + : undefined, + }, + "testIvB64", + ); + }, + ); + + it.each([[false], [true]])( + "should return the biometricKey if password is encrypted and cached clientKeyHalves is %s", + async (haveClientKeyHalves) => { + const clientKeyHalveBytes = new Uint8Array([1, 2, 3]); + if (haveClientKeyHalves) { + service["clientKeyHalves"].set(userId, clientKeyHalveBytes); + } + const biometricKey = key.toBase64(); + const biometricKeyEncrypted = "2.testId|data|mac"; + passwords.getPassword = jest.fn().mockResolvedValue(biometricKeyEncrypted); + biometrics.getBiometricSecret = jest.fn().mockResolvedValue(biometricKey); + biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({ + keyB64: "testKeyB64", + ivB64: "testIvB64", + } satisfies OsDerivedKey); + + const result = await service.getBiometricKey(userId); + + expect(result.toBase64()).toBe(biometricKey); + expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey); + expect(biometrics.setBiometricSecret).not.toHaveBeenCalled(); + expect(biometrics.getBiometricSecret).toHaveBeenCalledWith(serviceKey, storageKey, { + osKeyPartB64: "testKeyB64", + clientKeyPartB64: haveClientKeyHalves + ? Utils.fromBufferToB64(clientKeyHalveBytes) + : undefined, + }); + }, + ); + }); + + describe("deleteBiometricKey", () => { + const serviceName = "Bitwarden_biometric"; + const keyName = "test-user-id_user_biometric"; + + it("should delete biometric key successfully", async () => { + await service.deleteBiometricKey(userId); + + expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); + }); + + it.each([[false], [true]])("should not throw error if key found: %s", async (keyFound) => { + if (!keyFound) { + passwords.deletePassword = jest + .fn() + .mockRejectedValue(new Error(passwords.PASSWORD_NOT_FOUND)); + } + + await service.deleteBiometricKey(userId); + + expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); + if (!keyFound) { + expect(logService.debug).toHaveBeenCalledWith( + "[OsBiometricService] Biometric key %s not found for service %s.", + keyName, + serviceName, + ); + } + }); + + it("should throw error when deletePassword for key throws unexpected errors", async () => { + const error = new Error("Unexpected error"); + passwords.deletePassword = jest.fn().mockRejectedValue(error); + + await expect(service.deleteBiometricKey(userId)).rejects.toThrow(error); + + expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); + }); + }); + + describe("authenticateBiometric", () => { + const hwnd = randomBytes(32).buffer; + const consentMessage = "Test Windows Hello Consent Message"; + + beforeEach(() => { + windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(hwnd); + i18nService.t.mockReturnValue(consentMessage); + }); + + it("should return true when biometric authentication is successful", async () => { + const result = await service.authenticateBiometric(); + + expect(result).toBe(true); + expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage); + }); + + it("should return false when biometric authentication fails", async () => { + biometrics.prompt = jest.fn().mockResolvedValue(false); + + const result = await service.authenticateBiometric(); + + expect(result).toBe(false); + expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage); + }); + }); + + describe("getStorageDetails", () => { + it.each([ + ["testClientKeyHalfB64", "testIvB64"], + [undefined, "testIvB64"], + ["testClientKeyHalfB64", null], + [undefined, null], + ])( + "should derive key material and ivB64 and return it when os key half not saved yet", + async (clientKeyHalfB64, ivB64) => { + service["setIv"](ivB64); + + const derivedKeyMaterial = { + keyB64: "derivedKeyB64", + ivB64: "derivedIvB64", + }; + biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial); + + const result = await service["getStorageDetails"]({ clientKeyHalfB64 }); + + expect(result).toEqual({ + key_material: { + osKeyPartB64: derivedKeyMaterial.keyB64, + clientKeyPartB64: clientKeyHalfB64, + }, + ivB64: derivedKeyMaterial.ivB64, + }); + expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith(ivB64); + expect(service["_osKeyHalf"]).toEqual(derivedKeyMaterial.keyB64); + expect(service["_iv"]).toEqual(derivedKeyMaterial.ivB64); + }, + ); + + it("should throw an error when deriving key material and returned iv is null", async () => { + service["setIv"]("testIvB64"); + + const derivedKeyMaterial = { + keyB64: "derivedKeyB64", + ivB64: null as string | undefined | null, + }; + biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial); + + await expect( + service["getStorageDetails"]({ clientKeyHalfB64: "testClientKeyHalfB64" }), + ).rejects.toThrow("Initialization Vector is null"); + + expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith("testIvB64"); + }); + }); + + describe("setIv", () => { + it("should set the iv and reset the osKeyHalf", () => { + const iv = "testIv"; + service["_osKeyHalf"] = "testOsKeyHalf"; + + service["setIv"](iv); + + expect(service["_iv"]).toBe(iv); + expect(service["_osKeyHalf"]).toBeNull(); + }); + + it("should set the iv to null when iv is undefined and reset the osKeyHalf", () => { + service["_osKeyHalf"] = "testOsKeyHalf"; + + service["setIv"](undefined); + + expect(service["_iv"]).toBeNull(); + expect(service["_osKeyHalf"]).toBeNull(); + }); + }); +}); diff --git a/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-windows.service.ts b/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-windows.service.ts new file mode 100644 index 00000000000..514a4a9b654 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v1/os-biometrics-windows.service.ts @@ -0,0 +1,213 @@ +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 { 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 { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management"; + +import { WindowMain } from "../../../main/window.main"; +import { OsBiometricService } from "../native-v2/os-biometrics.service"; + +const SERVICE = "Bitwarden_biometric"; + +function getLookupKeyForUser(userId: UserId): string { + return `${userId}_user_biometric`; +} + +export default class OsBiometricsServiceWindows implements OsBiometricService { + // Use set helper method instead of direct access + private _iv: string | null = null; + // Use getKeyMaterial helper instead of direct access + private _osKeyHalf: string | null = null; + private clientKeyHalves = new Map(); + + constructor( + private i18nService: I18nService, + private windowMain: WindowMain, + private logService: LogService, + private biometricStateService: BiometricStateService, + private encryptService: EncryptService, + private cryptoFunctionService: CryptoFunctionService, + ) {} + + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise {} + + async hasPersistentKey(userId: UserId): Promise { + return false; + } + + async supportsBiometrics(): Promise { + return await biometrics.available(); + } + + 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, + ), + ); + } + } + + 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, + ); + } + + async deleteBiometricKey(userId: UserId): Promise { + try { + await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId)); + } catch (e) { + if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) { + this.logService.debug( + "[OsBiometricService] Biometric key %s not found for service %s.", + getLookupKeyForUser(userId), + SERVICE, + ); + } else { + throw e; + } + } + } + + /** + * Prompts Windows Hello + */ + async authenticateBiometric(): Promise { + const hwnd = this.windowMain.win.getNativeWindowHandle(); + return await biometrics.prompt(hwnd, this.i18nService.t("windowsHelloConsentMessage")); + } + + private async getStorageDetails({ + clientKeyHalfB64, + }: { + clientKeyHalfB64: string | undefined; + }): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> { + if (this._osKeyHalf == null) { + const keyMaterial = await biometrics.deriveKeyMaterial(this._iv); + this._osKeyHalf = keyMaterial.keyB64; + this._iv = keyMaterial.ivB64; + } + + if (this._iv == null) { + throw new Error("Initialization Vector is null"); + } + + const result = { + key_material: { + osKeyPartB64: this._osKeyHalf, + clientKeyPartB64: clientKeyHalfB64, + }, + ivB64: this._iv, + }; + + // napi-rs fails to convert null values + if (result.key_material.clientKeyPartB64 == null) { + delete result.key_material.clientKeyPartB64; + } + return result; + } + + // Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey + // when we want to force a re-derive of the key material. + private setIv(iv?: string) { + this._iv = iv ?? null; + this._osKeyHalf = null; + } + + async needsSetup() { + return false; + } + + async canAutoSetup(): Promise { + return false; + } + + async runSetup(): Promise {} + + async getOrCreateBiometricEncryptionClientKeyHalf( + userId: UserId, + key: SymmetricCryptoKey, + ): Promise { + if (this.clientKeyHalves.has(userId)) { + return this.clientKeyHalves.get(userId)!; + } + + // Retrieve existing key half if it exists + let clientKeyHalf: Uint8Array | null = null; + const encryptedClientKeyHalf = + await this.biometricStateService.getEncryptedClientKeyHalf(userId); + if (encryptedClientKeyHalf != null) { + clientKeyHalf = await this.encryptService.decryptBytes(encryptedClientKeyHalf, key); + } + if (clientKeyHalf == null) { + // Set a key half if it doesn't exist + clientKeyHalf = await this.cryptoFunctionService.randomBytes(32); + const encKey = await this.encryptService.encryptBytes(clientKeyHalf, key); + await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId); + } + + this.clientKeyHalves.set(userId, clientKeyHalf); + + return clientKeyHalf; + } + + async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { + if (this.clientKeyHalves.has(userId)) { + return BiometricsStatus.Available; + } else { + return BiometricsStatus.UnlockNeeded; + } + } +} 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 00000000000..473c490bcc8 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/index.ts @@ -0,0 +1,3 @@ +export { default as LinuxBiometricsSystem } from "./os-biometrics-linux.service"; +export { default as MacBiometricsSystem } from "./os-biometrics-mac.service"; +export { default as WindowsBiometricsSystem } from "./os-biometrics-windows.service"; diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.spec.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.spec.ts similarity index 100% rename from apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.spec.ts rename to apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.spec.ts diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.ts similarity index 82% rename from apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts rename to apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.ts index 45bc040d367..95a2583ce4f 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts +++ b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.ts @@ -2,10 +2,10 @@ import { spawn } from "child_process"; 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_v2, passwords } from "@bitwarden/desktop-napi"; import { BiometricsStatus } from "@bitwarden/key-management"; -import { isFlatpak, isLinux, isSnapStore } from "../../utils"; +import { isSnapStore, isFlatpak, isLinux } from "../../../utils"; import { OsBiometricService } from "./os-biometrics.service"; @@ -29,25 +29,31 @@ const policyFileName = "com.bitwarden.Bitwarden.policy"; const policyPath = "/usr/share/polkit-1/actions/"; export default class OsBiometricsServiceLinux implements OsBiometricService { - private biometricsSystem = biometrics.initBiometricSystem(); + private biometricsSystem; - constructor() {} + constructor() { + this.biometricsSystem = biometrics_v2.initBiometricSystem(); + } async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { - await biometrics.provideKey(this.biometricsSystem, userId, Buffer.from(key.toEncoded().buffer)); + await biometrics_v2.provideKey( + this.biometricsSystem, + userId, + Buffer.from(key.toEncoded().buffer), + ); } async deleteBiometricKey(userId: UserId): Promise { - await biometrics.unenroll(this.biometricsSystem, userId); + await biometrics_v2.unenroll(this.biometricsSystem, userId); } async getBiometricKey(userId: UserId): Promise { - const result = await biometrics.unlock(this.biometricsSystem, userId, Buffer.from("")); + const result = await biometrics_v2.unlock(this.biometricsSystem, userId, Buffer.from("")); return result ? new SymmetricCryptoKey(Uint8Array.from(result)) : null; } async authenticateBiometric(): Promise { - return await biometrics.authenticate( + return await biometrics_v2.authenticate( this.biometricsSystem, Buffer.from(""), "Authenticate to unlock", @@ -70,7 +76,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { } // check whether the polkit policy is loaded via dbus call to polkit - return !(await biometrics.authenticateAvailable(this.biometricsSystem)); + return !(await biometrics_v2.authenticateAvailable(this.biometricsSystem)); } async canAutoSetup(): Promise { @@ -100,7 +106,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { } async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { - return (await biometrics.unlockAvailable(this.biometricsSystem, userId)) + return (await biometrics_v2.unlockAvailable(this.biometricsSystem, userId)) ? BiometricsStatus.Available : BiometricsStatus.UnlockNeeded; } diff --git a/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-mac.service.spec.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-mac.service.spec.ts new file mode 100644 index 00000000000..6d20095d8bb --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-mac.service.spec.ts @@ -0,0 +1,78 @@ +import { mock } from "jest-mock-extended"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { passwords } from "@bitwarden/desktop-napi"; + +import OsBiometricsServiceMac from "./os-biometrics-mac.service"; + +jest.mock("@bitwarden/desktop-napi", () => ({ + biometrics: { + setBiometricSecret: jest.fn(), + getBiometricSecret: jest.fn(), + deleteBiometricSecret: jest.fn(), + prompt: jest.fn(), + available: jest.fn(), + deriveKeyMaterial: jest.fn(), + }, + passwords: { + deletePassword: jest.fn(), + getPassword: jest.fn(), + isAvailable: jest.fn(), + PASSWORD_NOT_FOUND: "Password not found", + }, +})); + +describe("OsBiometricsServiceMac", () => { + let service: OsBiometricsServiceMac; + let i18nService: I18nService; + let logService: LogService; + + const mockUserId = "test-user-id" as UserId; + + beforeEach(() => { + i18nService = mock(); + logService = mock(); + service = new OsBiometricsServiceMac(i18nService, logService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("deleteBiometricKey", () => { + const serviceName = "Bitwarden_biometric"; + const keyName = "test-user-id_user_biometric"; + + it("should delete biometric key successfully", async () => { + await service.deleteBiometricKey(mockUserId); + + expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); + }); + + it("should not throw error if key not found", async () => { + passwords.deletePassword = jest + .fn() + .mockRejectedValueOnce(new Error(passwords.PASSWORD_NOT_FOUND)); + + await service.deleteBiometricKey(mockUserId); + + expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); + expect(logService.debug).toHaveBeenCalledWith( + "[OsBiometricService] Biometric key %s not found for service %s.", + keyName, + serviceName, + ); + }); + + it("should throw error for unexpected errors", async () => { + const error = new Error("Unexpected error"); + passwords.deletePassword = jest.fn().mockRejectedValueOnce(error); + + await expect(service.deleteBiometricKey(mockUserId)).rejects.toThrow(error); + + expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); + }); + }); +}); diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-mac.service.ts similarity index 100% rename from apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts rename to apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-mac.service.ts diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.spec.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.spec.ts similarity index 100% rename from apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.spec.ts rename to apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.spec.ts diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.ts similarity index 65% rename from apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts rename to apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.ts index 163e4a2c2d0..6e5d2bbf160 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts +++ b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.ts @@ -1,23 +1,25 @@ 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 } from "@bitwarden/desktop-napi"; +import { biometrics_v2 } from "@bitwarden/desktop-napi"; import { BiometricsStatus } from "@bitwarden/key-management"; -import { WindowMain } from "../../main/window.main"; +import { WindowMain } from "../../../main/window.main"; import { OsBiometricService } from "./os-biometrics.service"; export default class OsBiometricsServiceWindows implements OsBiometricService { - private biometricsSystem = biometrics.initBiometricSystem(); + private biometricsSystem; constructor( private i18nService: I18nService, private windowMain: WindowMain, - ) {} + ) { + this.biometricsSystem = biometrics_v2.initBiometricSystem(); + } async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { - await biometrics.enrollPersistent( + await biometrics_v2.enrollPersistent( this.biometricsSystem, userId, Buffer.from(key.toEncoded().buffer), @@ -25,37 +27,41 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { } async hasPersistentKey(userId: UserId): Promise { - return await biometrics.hasPersistent(this.biometricsSystem, userId); + return await biometrics_v2.hasPersistent(this.biometricsSystem, userId); } async supportsBiometrics(): Promise { - return await biometrics.authenticateAvailable(this.biometricsSystem); + return await biometrics_v2.authenticateAvailable(this.biometricsSystem); } async getBiometricKey(userId: UserId): Promise { try { - const key = await biometrics.unlock( + const key = await biometrics_v2.unlock( this.biometricsSystem, userId, this.windowMain.win.getNativeWindowHandle(), ); - return new SymmetricCryptoKey(Uint8Array.from(key)); + return key ? new SymmetricCryptoKey(Uint8Array.from(key)) : null; } catch { return null; } } async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { - await biometrics.provideKey(this.biometricsSystem, userId, Buffer.from(key.toEncoded().buffer)); + await biometrics_v2.provideKey( + this.biometricsSystem, + userId, + Buffer.from(key.toEncoded().buffer), + ); } async deleteBiometricKey(userId: UserId): Promise { - await biometrics.unenroll(this.biometricsSystem, userId); + await biometrics_v2.unenroll(this.biometricsSystem, userId); } async authenticateBiometric(): Promise { const hwnd = this.windowMain.win.getNativeWindowHandle(); - return await biometrics.authenticate( + return await biometrics_v2.authenticate( this.biometricsSystem, hwnd, this.i18nService.t("windowsHelloConsentMessage"), @@ -73,8 +79,8 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { async runSetup(): Promise {} async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { - return (await biometrics.hasPersistent(this.biometricsSystem, userId)) || - (await biometrics.unlockAvailable(this.biometricsSystem, userId)) + 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.service.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics.service.ts similarity index 100% rename from apps/desktop/src/key-management/biometrics/os-biometrics.service.ts rename to apps/desktop/src/key-management/biometrics/native-v2/os-biometrics.service.ts 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 0edd7308b45..09ad13e4868 100644 --- a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts @@ -76,4 +76,8 @@ export class RendererBiometricsService extends DesktopBiometricsService { async hasPersistentKey(userId: UserId): Promise { return await ipc.keyManagement.biometric.hasPersistentKey(userId); } + + async enableV2BiometricsBackend(): Promise { + return await ipc.keyManagement.biometric.enableBiometricsV2(); + } } diff --git a/apps/desktop/src/key-management/preload.ts b/apps/desktop/src/key-management/preload.ts index 255c2c26270..e6cd2b78525 100644 --- a/apps/desktop/src/key-management/preload.ts +++ b/apps/desktop/src/key-management/preload.ts @@ -61,6 +61,10 @@ const biometric = { action: BiometricAction.HasPersistentKey, userId: userId, } satisfies BiometricMessage), + enableBiometricsV2: (): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.EnableV2, + } satisfies BiometricMessage), }; export default { diff --git a/apps/desktop/src/services/biometric-message-handler.service.ts b/apps/desktop/src/services/biometric-message-handler.service.ts index 04cebbfc93d..502ce04b397 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.ts @@ -4,26 +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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.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"; @@ -83,15 +78,31 @@ export class BiometricMessageHandlerService { private logService: LogService, private messagingService: MessagingService, private desktopSettingService: DesktopSettingsService, - private biometricStateService: BiometricStateService, - private biometricsService: BiometricsService, + private biometricsService: DesktopBiometricsService, + private configService: ConfigService, private dialogService: DialogService, private accountService: AccountService, private authService: AuthService, private ngZone: NgZone, - private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, ) { + // This will be removed after the flag is rolled out + this.configService + .getFeatureFlag(FeatureFlag.SystemBiometricsV2) + .then(async (enabled) => { + this.logService.info( + "[Native Messaging IPC] SystemBiometricsV2 feature flag is " + enabled, + ); + if (enabled) { + await this.biometricsService.enableV2BiometricsBackend(); + } + }) + .catch((e) => { + this.logService.error( + "[Native Messaging IPC] Failed to get SystemBiometricsV2 feature flag", + e, + ); + }); + combineLatest([ this.desktopSettingService.browserIntegrationEnabled$, this.desktopSettingService.browserIntegrationFingerprintEnabled$, diff --git a/apps/desktop/src/types/biometric-message.ts b/apps/desktop/src/types/biometric-message.ts index 52224a17736..19d837f19c4 100644 --- a/apps/desktop/src/types/biometric-message.ts +++ b/apps/desktop/src/types/biometric-message.ts @@ -16,6 +16,8 @@ export enum BiometricAction { EnrollPersistent = "enrollPersistent", HasPersistentKey = "hasPersistentKey", + + EnableV2 = "enableV2", } export type BiometricMessage = diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index b339798f914..176a191062a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -34,6 +34,7 @@ export enum FeatureFlag { PrivateKeyRegeneration = "pm-12241-private-key-regeneration", EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation", ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", + SystemBiometricsV2 = "system-biometrics-v2", /* Tools */ DesktopSendUIRefresh = "desktop-send-ui-refresh", @@ -109,6 +110,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.EnrollAeadOnKeyRotation]: FALSE, [FeatureFlag.ForceUpdateKDFSettings]: FALSE, + [FeatureFlag.SystemBiometricsV2]: FALSE, /* Platform */ [FeatureFlag.IpcChannelFramework]: FALSE, diff --git a/package-lock.json b/package-lock.json index 4bd1238b27e..7eb9a3ffb30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -393,7 +393,8 @@ "license": "GPL-3.0" }, "libs/pricing": { - "version": "0.0.1", + "name": "@bitwarden/pricing", + "version": "0.0.0", "license": "GPL-3.0" }, "libs/serialization": {