1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-25373] Windows native biometric rewrite (#16432)

* Extract windows biometrics v2 changes

Co-authored-by: Bernd Schoolmann <mail@quexten.com>

* Handle TDE edge cases

* Make windows rust code async and fix restoring focus freezes

* Add unit test coverage

---------

Co-authored-by: Bernd Schoolmann <mail@quexten.com>
This commit is contained in:
Thomas Avery
2025-10-20 14:47:15 -05:00
committed by GitHub
parent d2c6757626
commit f65e5d52c2
35 changed files with 1971 additions and 182 deletions

View File

@@ -591,6 +591,19 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -904,6 +917,7 @@ dependencies = [
"byteorder",
"bytes",
"cbc",
"chacha20poly1305",
"core-foundation",
"desktop_objc",
"dirs",
@@ -923,6 +937,8 @@ dependencies = [
"secmem-proc",
"security-framework",
"security-framework-sys",
"serde",
"serde_json",
"sha2",
"ssh-encoding",
"ssh-key",

View File

@@ -27,6 +27,7 @@ bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", re
byteorder = "=1.5.0"
bytes = "=1.10.1"
cbc = "=0.1.2"
chacha20poly1305 = "=0.10.1"
core-foundation = "=0.10.1"
ctor = "=0.5.0"
dirs = "=6.0.0"

View File

@@ -26,6 +26,7 @@ bitwarden-russh = { workspace = true }
byteorder = { workspace = true }
bytes = { workspace = true }
cbc = { workspace = true, features = ["alloc"] }
chacha20poly1305 = { workspace = true }
dirs = { workspace = true }
ed25519 = { workspace = true, features = ["pkcs8"] }
futures = { workspace = true }
@@ -38,6 +39,8 @@ rsa = { workspace = true }
russh-cryptovec = { workspace = true }
scopeguard = { workspace = true }
secmem-proc = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
ssh-encoding = { workspace = true }
ssh-key = { workspace = true, features = [
@@ -64,6 +67,7 @@ windows = { workspace = true, features = [
"Storage_Streams",
"Win32_Foundation",
"Win32_Security_Credentials",
"Win32_Security_Cryptography",
"Win32_System_WinRT",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",

View File

@@ -0,0 +1,33 @@
use anyhow::Result;
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "unimplemented.rs")]
#[cfg_attr(target_os = "macos", path = "unimplemented.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
mod biometric_v2;
#[cfg(target_os = "windows")]
pub mod windows_focus;
pub use biometric_v2::BiometricLockSystem;
#[allow(async_fn_in_trait)]
pub trait BiometricTrait: Send + Sync {
/// Authenticate the user
async fn authenticate(&self, hwnd: Vec<u8>, message: String) -> Result<bool>;
/// Check if biometric authentication is available
async fn authenticate_available(&self) -> Result<bool>;
/// 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<bool>;
/// Provide a key to be ephemerally held. This should be called on every unlock.
async fn provide_key(&self, user_id: &str, key: &[u8]);
/// Perform biometric unlock and return the key
async fn unlock(&self, user_id: &str, hwnd: Vec<u8>) -> Result<Vec<u8>>;
/// 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<bool>;
}

View File

@@ -0,0 +1,47 @@
pub struct BiometricLockSystem {}
impl BiometricLockSystem {
pub fn new() -> Self {
Self {}
}
}
impl Default for BiometricLockSystem {
fn default() -> Self {
Self::new()
}
}
impl super::BiometricTrait for BiometricLockSystem {
async fn authenticate(&self, _hwnd: Vec<u8>, _message: String) -> Result<bool, anyhow::Error> {
unimplemented!()
}
async fn authenticate_available(&self) -> Result<bool, anyhow::Error> {
unimplemented!()
}
async fn enroll_persistent(&self, _user_id: &str, _key: &[u8]) -> Result<(), anyhow::Error> {
unimplemented!()
}
async fn provide_key(&self, _user_id: &str, _key: &[u8]) {
unimplemented!()
}
async fn unlock(&self, _user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>, anyhow::Error> {
unimplemented!()
}
async fn unlock_available(&self, _user_id: &str) -> Result<bool, anyhow::Error> {
unimplemented!()
}
async fn has_persistent(&self, _user_id: &str) -> Result<bool, anyhow::Error> {
unimplemented!()
}
async fn unenroll(&self, _user_id: &str) -> Result<(), anyhow::Error> {
unimplemented!()
}
}

View File

@@ -0,0 +1,505 @@
//! This file implements Windows-Hello based biometric unlock.
//!
//! There are two paths implemented here.
//! The former via UV + ephemerally (but protected) keys. This only works after first unlock.
//! The latter via a signing API, that deterministically signs a challenge, from which a windows hello key is derived. This key
//! is used to encrypt the protected key.
//!
//! # Security
//! The security goal is that a locked vault - a running app - cannot be unlocked when the device (user-space)
//! is compromised in this state.
//!
//! ## UV path
//! When first unlocking the app, the app sends the user-key to this module, which holds it in secure memory,
//! protected by DPAPI. This makes it inaccessible to other processes, unless they compromise the system administrator, or kernel.
//! While the app is running this key is held in memory, even if locked. When unlocking, the app will prompt the user via
//! `windows_hello_authenticate` to get a yes/no decision on whether to release the key to the app.
//! Note: Further process isolation is needed here so that code cannot be injected into the running process, which may
//! circumvent DPAPI.
//!
//! ## Sign path
//! In this scenario, when enrolling, the app sends the user-key to this module, which derives the windows hello key
//! with the Windows Hello prompt. This is done by signing a per-user challenge, which produces a deterministic
//! signature which is hashed to obtain a key. This key is used to encrypt and persist the vault unlock key (user key).
//!
//! Since the keychain can be accessed by all user-space processes, the challenge is known to all userspace processes.
//! Therefore, to circumvent the security measure, the attacker would need to create a fake Windows-Hello prompt, and
//! get the user to confirm it.
use std::sync::{atomic::AtomicBool, Arc};
use tracing::{debug, warn};
use aes::cipher::KeyInit;
use anyhow::{anyhow, Result};
use chacha20poly1305::{aead::Aead, XChaCha20Poly1305, XNonce};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use windows::{
core::{factory, h, Interface, HSTRING},
Security::{
Credentials::{
KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus,
UI::{
UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability,
},
},
Cryptography::CryptographicBuffer,
},
Storage::Streams::IBuffer,
Win32::{
System::WinRT::{IBufferByteAccess, IUserConsentVerifierInterop},
UI::WindowsAndMessaging::GetForegroundWindow,
},
};
use windows_future::IAsyncOperation;
use super::windows_focus::{focus_security_prompt, restore_focus};
use crate::{
password::{self, PASSWORD_NOT_FOUND},
secure_memory::*,
};
const KEYCHAIN_SERVICE_NAME: &str = "BitwardenBiometricsV2";
const CREDENTIAL_NAME: &HSTRING = h!("BitwardenBiometricsV2");
const CHALLENGE_LENGTH: usize = 16;
const XCHACHA20POLY1305_NONCE_LENGTH: usize = 24;
const XCHACHA20POLY1305_KEY_LENGTH: usize = 32;
#[derive(serde::Serialize, serde::Deserialize)]
struct WindowsHelloKeychainEntry {
nonce: [u8; XCHACHA20POLY1305_NONCE_LENGTH],
challenge: [u8; CHALLENGE_LENGTH],
wrapped_key: Vec<u8>,
}
/// 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<Mutex<crate::secure_memory::dpapi::DpapiSecretKVStore>>,
}
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<u8>, message: String) -> Result<bool> {
windows_hello_authenticate(message).await
}
async fn authenticate_available(&self) -> Result<bool> {
match UserConsentVerifier::CheckAvailabilityAsync()?.await? {
UserConsentVerifierAvailability::Available
| UserConsentVerifierAvailability::DeviceBusy => Ok(true),
_ => Ok(false),
}
}
async fn unenroll(&self, user_id: &str) -> Result<()> {
self.secure_memory.lock().await.remove(user_id);
delete_keychain_entry(user_id).await
}
async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()> {
// Enrollment works by first generating a random challenge unique to the user / enrollment. Then,
// with the challenge and a Windows-Hello prompt, the "windows hello key" is derived. The windows
// hello key is used to encrypt the key to store with XChaCha20Poly1305. The bundle of nonce,
// challenge and wrapped-key are stored to the keychain
// Each enrollment (per user) has a unique challenge, so that the windows-hello key is unique
let challenge: [u8; CHALLENGE_LENGTH] = rand::random();
// This key is unique to the challenge
let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge).await?;
let (wrapped_key, nonce) = encrypt_data(&windows_hello_key, key)?;
set_keychain_entry(
user_id,
&WindowsHelloKeychainEntry {
nonce,
challenge,
wrapped_key,
},
)
.await
}
async fn provide_key(&self, user_id: &str, key: &[u8]) {
self.secure_memory
.lock()
.await
.put(user_id.to_string(), key);
}
async fn unlock(&self, user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>> {
// Allow restoring focus to the previous window (browser)
let previous_active_window = super::windows_focus::get_active_window();
let _focus_scopeguard = scopeguard::guard((), |_| {
if let Some(hwnd) = previous_active_window {
debug!("Restoring focus to previous window");
restore_focus(hwnd.0);
}
});
let mut secure_memory = self.secure_memory.lock().await;
// If the key is held ephemerally, always use UV API. Only use signing API if the key is not held
// ephemerally but the keychain holds it persistently.
if secure_memory.has(user_id) {
if windows_hello_authenticate("Unlock your vault".to_string()).await? {
secure_memory
.get(user_id)
.clone()
.ok_or_else(|| anyhow!("No key found for user"))
} else {
Err(anyhow!("Authentication failed"))
}
} else {
let keychain_entry = get_keychain_entry(user_id).await?;
let windows_hello_key =
windows_hello_authenticate_with_crypto(&keychain_entry.challenge).await?;
let decrypted_key = decrypt_data(
&windows_hello_key,
&keychain_entry.wrapped_key,
&keychain_entry.nonce,
)?;
// The first unlock already sets the key for subsequent unlocks. The key may again be set externally after unlock finishes.
secure_memory.put(user_id.to_string(), &decrypted_key.clone());
Ok(decrypted_key)
}
}
async fn unlock_available(&self, user_id: &str) -> Result<bool> {
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<bool> {
Ok(get_keychain_entry(user_id).await.is_ok())
}
}
/// Get a yes/no authorization without any cryptographic backing.
/// This API has better focusing behavior
async fn windows_hello_authenticate(message: String) -> Result<bool> {
debug!(
"[Windows Hello] Authenticating to perform UV with message: {}",
message
);
let userconsent_result: IAsyncOperation<UserConsentVerificationResult> = unsafe {
// Windows Hello prompt must be in foreground, focused, otherwise the face or fingerprint
// unlock will not work. We get the current foreground window, which will either be the
// Bitwarden desktop app or the browser extension.
let foreground_window = GetForegroundWindow();
factory::<UserConsentVerifier, IUserConsentVerifierInterop>()?
.RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))?
};
match userconsent_result.await? {
UserConsentVerificationResult::Verified => Ok(true),
_ => Ok(false),
}
}
/// Derive the symmetric encryption key from the Windows Hello signature.
///
/// This works by signing a static challenge string with Windows Hello protected key store. The
/// signed challenge is then hashed using SHA-256 and used as the symmetric encryption key for the
/// Windows Hello protected keys.
///
/// Windows will only sign the challenge if the user has successfully authenticated with Windows,
/// ensuring user presence.
///
/// Note: This API has inconsistent focusing behavior when called from another window
async fn windows_hello_authenticate_with_crypto(
challenge: &[u8; CHALLENGE_LENGTH],
) -> Result<[u8; XCHACHA20POLY1305_KEY_LENGTH]> {
debug!("[Windows Hello] Authenticating to sign challenge");
// Ugly hack: We need to focus the window via window focusing APIs until Microsoft releases a new API.
// This is unreliable, and if it does not work, the operation may fail
let stop_focusing = Arc::new(AtomicBool::new(false));
let stop_focusing_clone = stop_focusing.clone();
let _ = std::thread::spawn(move || loop {
if !stop_focusing_clone.load(std::sync::atomic::Ordering::Relaxed) {
focus_security_prompt();
std::thread::sleep(std::time::Duration::from_millis(500));
} else {
break;
}
});
// Only stop focusing once this function exits. The focus MUST run both during the initial creation
// with RequestCreateAsync, and also with the subsequent use with RequestSignAsync.
let _guard = scopeguard::guard((), |_| {
stop_focusing.store(true, std::sync::atomic::Ordering::Relaxed);
});
// First create or replace the Bitwarden Biometrics signing key
let credential = {
let key_credential_creation_result = KeyCredentialManager::RequestCreateAsync(
CREDENTIAL_NAME,
KeyCredentialCreationOption::FailIfExists,
)?
.await?;
match key_credential_creation_result.Status()? {
KeyCredentialStatus::CredentialAlreadyExists => {
KeyCredentialManager::OpenAsync(CREDENTIAL_NAME)?.await?
}
KeyCredentialStatus::Success => key_credential_creation_result,
_ => return Err(anyhow!("Failed to create key credential")),
}
}
.Credential()?;
let signature = {
let sign_operation = credential.RequestSignAsync(
&CryptographicBuffer::CreateFromByteArray(challenge.as_slice())?,
)?;
// We need to drop the credential here to avoid holding it across an await point.
drop(credential);
sign_operation.await?
};
if signature.Status()? != KeyCredentialStatus::Success {
return Err(anyhow!("Failed to sign data"));
}
let signature_buffer = signature.Result()?;
let signature_value = unsafe { as_mut_bytes(&signature_buffer)? };
// The signature is deterministic based on the challenge and keychain key. Thus, it can be hashed to a key.
// It is unclear what entropy this key provides.
let windows_hello_key = Sha256::digest(signature_value).into();
Ok(windows_hello_key)
}
async fn set_keychain_entry(user_id: &str, entry: &WindowsHelloKeychainEntry) -> Result<()> {
password::set_password(
KEYCHAIN_SERVICE_NAME,
user_id,
&serde_json::to_string(entry)?,
)
.await
}
async fn get_keychain_entry(user_id: &str) -> Result<WindowsHelloKeychainEntry> {
serde_json::from_str(&password::get_password(KEYCHAIN_SERVICE_NAME, user_id).await?)
.map_err(|e| anyhow!(e))
}
async fn delete_keychain_entry(user_id: &str) -> Result<()> {
password::delete_password(KEYCHAIN_SERVICE_NAME, user_id)
.await
.or_else(|e| {
if e.to_string() == PASSWORD_NOT_FOUND {
debug!(
"[Windows Hello] No keychain entry found for user {}, nothing to delete",
user_id
);
Ok(())
} else {
Err(e)
}
})
}
async fn has_keychain_entry(user_id: &str) -> Result<bool> {
password::get_password(KEYCHAIN_SERVICE_NAME, user_id)
.await
.map(|entry| !entry.is_empty())
.or_else(|e| {
if e.to_string() == PASSWORD_NOT_FOUND {
Ok(false)
} else {
warn!(
"[Windows Hello] Error checking keychain entry for user {}: {}",
user_id, e
);
Err(e)
}
})
}
/// Encrypt data with XChaCha20Poly1305
fn encrypt_data(
key: &[u8; XCHACHA20POLY1305_KEY_LENGTH],
plaintext: &[u8],
) -> Result<(Vec<u8>, [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<Vec<u8>> {
let cipher = XChaCha20Poly1305::new(key.into());
let plaintext = cipher
.decrypt(XNonce::from_slice(nonce), ciphertext)
.map_err(|e| anyhow!(e))?;
Ok(plaintext)
}
unsafe fn as_mut_bytes(buffer: &IBuffer) -> Result<&mut [u8]> {
let interop = buffer.cast::<IBufferByteAccess>()?;
unsafe {
let data = interop.Buffer()?;
Ok(std::slice::from_raw_parts_mut(
data,
buffer.Length()? as usize,
))
}
}
#[cfg(test)]
mod tests {
use crate::biometric_v2::{
biometric_v2::{
decrypt_data, encrypt_data, has_keychain_entry, windows_hello_authenticate,
windows_hello_authenticate_with_crypto, CHALLENGE_LENGTH, XCHACHA20POLY1305_KEY_LENGTH,
},
BiometricLockSystem, BiometricTrait,
};
#[test]
fn test_encrypt_decrypt() {
let key = [0u8; 32];
let plaintext = b"Test data";
let (ciphertext, nonce) = encrypt_data(&key, plaintext).unwrap();
let decrypted = decrypt_data(&key, &ciphertext, &nonce).unwrap();
assert_eq!(plaintext.to_vec(), decrypted);
}
#[tokio::test]
async fn test_has_keychain_entry_no_entry() {
let user_id = "test_user";
let has_entry = has_keychain_entry(user_id).await.unwrap();
assert!(!has_entry);
}
// Note: These tests are ignored because they require manual intervention to run
#[tokio::test]
#[ignore]
async fn test_windows_hello_authenticate_with_crypto_manual() {
let challenge = [0u8; CHALLENGE_LENGTH];
let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge)
.await
.unwrap();
println!(
"Windows hello key {:?} for challenge {:?}",
windows_hello_key, challenge
);
}
#[tokio::test]
#[ignore]
async fn test_windows_hello_authenticate() {
let authenticated =
windows_hello_authenticate("Test Windows Hello authentication".to_string())
.await
.unwrap();
println!("Windows Hello authentication result: {:?}", authenticated);
}
#[tokio::test]
#[ignore]
async fn test_double_unenroll() {
let user_id = "test_user";
let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH];
rand::fill(&mut key);
let windows_hello_lock_system = BiometricLockSystem::new();
println!("Enrolling user");
windows_hello_lock_system
.enroll_persistent(user_id, &key)
.await
.unwrap();
assert!(windows_hello_lock_system
.has_persistent(user_id)
.await
.unwrap());
println!("Unlocking user");
let key_after_unlock = windows_hello_lock_system
.unlock(user_id, Vec::new())
.await
.unwrap();
assert_eq!(key_after_unlock, key);
println!("Unenrolling user");
windows_hello_lock_system.unenroll(user_id).await.unwrap();
assert!(!windows_hello_lock_system
.has_persistent(user_id)
.await
.unwrap());
println!("Unenrolling user again");
// This throws PASSWORD_NOT_FOUND but our code should handle that and not throw.
windows_hello_lock_system.unenroll(user_id).await.unwrap();
assert!(!windows_hello_lock_system
.has_persistent(user_id)
.await
.unwrap());
}
#[tokio::test]
#[ignore]
async fn test_enroll_unlock_unenroll() {
let user_id = "test_user";
let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH];
rand::fill(&mut key);
let windows_hello_lock_system = BiometricLockSystem::new();
println!("Enrolling user");
windows_hello_lock_system
.enroll_persistent(user_id, &key)
.await
.unwrap();
assert!(windows_hello_lock_system
.has_persistent(user_id)
.await
.unwrap());
println!("Unlocking user");
let key_after_unlock = windows_hello_lock_system
.unlock(user_id, Vec::new())
.await
.unwrap();
assert_eq!(key_after_unlock, key);
println!("Unenrolling user");
windows_hello_lock_system.unenroll(user_id).await.unwrap();
assert!(!windows_hello_lock_system
.has_persistent(user_id)
.await
.unwrap());
}
}

View File

@@ -0,0 +1,100 @@
use windows::{
core::s,
Win32::{
Foundation::HWND,
System::Threading::{AttachThreadInput, GetCurrentThreadId},
UI::{
Input::KeyboardAndMouse::{EnableWindow, SetActiveWindow, SetCapture, SetFocus},
WindowsAndMessaging::{
BringWindowToTop, FindWindowA, GetForegroundWindow, GetWindowThreadProcessId,
SetForegroundWindow, SwitchToThisWindow, SystemParametersInfoW, SPIF_SENDCHANGE,
SPIF_UPDATEINIFILE, SPI_GETFOREGROUNDLOCKTIMEOUT, SPI_SETFOREGROUNDLOCKTIMEOUT,
},
},
},
};
pub(crate) struct HwndHolder(pub(crate) HWND);
unsafe impl Send for HwndHolder {}
pub(crate) fn get_active_window() -> Option<HwndHolder> {
unsafe { Some(HwndHolder(GetForegroundWindow())) }
}
/// Searches for a window that looks like a security prompt and set it as focused.
/// Only works when the process has permission to foreground, either by being in foreground
/// Or by being given foreground permission https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks
pub fn focus_security_prompt() {
let hwnd_result = unsafe { FindWindowA(s!("Credential Dialog Xaml Host"), None) };
if let Ok(hwnd) = hwnd_result {
set_focus(hwnd);
}
}
/// Sets focus to a window using a few unstable methods
fn set_focus(hwnd: HWND) {
unsafe {
// Windows REALLY does not like apps stealing focus, even if it is for fixing Windows-Hello bugs.
// The windows hello signing prompt NEEDS to be focused instantly, or it will error, but it does
// not focus itself.
// This function implements forced focusing of windows using a few hacks.
// The conditions to successfully foreground a window are:
// All of the following conditions are true:
// The calling process belongs to a desktop application, not a UWP app or a Windows Store app designed for Windows 8 or 8.1.
// The foreground process has not disabled calls to SetForegroundWindow by a previous call to the LockSetForegroundWindow function.
// The foreground lock time-out has expired (see SPI_GETFOREGROUNDLOCKTIMEOUT in SystemParametersInfo).
// No menus are active.
// Additionally, at least one of the following conditions is true:
// The calling process is the foreground process.
// The calling process was started by the foreground process.
// There is currently no foreground window, and thus no foreground process.
// The calling process received the last input event.
// Either the foreground process or the calling process is being debugged.
// Update the foreground lock timeout temporarily
let mut old_timeout = 0;
let _ = SystemParametersInfoW(
SPI_GETFOREGROUNDLOCKTIMEOUT,
0,
Some(&mut old_timeout as *mut _ as *mut std::ffi::c_void),
windows::Win32::UI::WindowsAndMessaging::SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
);
let _ = SystemParametersInfoW(
SPI_SETFOREGROUNDLOCKTIMEOUT,
0,
None,
SPIF_UPDATEINIFILE | SPIF_SENDCHANGE,
);
let _scopeguard = scopeguard::guard((), |_| {
let _ = SystemParametersInfoW(
SPI_SETFOREGROUNDLOCKTIMEOUT,
old_timeout,
None,
SPIF_UPDATEINIFILE | SPIF_SENDCHANGE,
);
});
// Attach to the foreground thread once attached, we can foreground, even if in the background
let dw_current_thread = GetCurrentThreadId();
let dw_fg_thread = GetWindowThreadProcessId(GetForegroundWindow(), None);
let _ = AttachThreadInput(dw_current_thread, dw_fg_thread, true);
let _ = SetForegroundWindow(hwnd);
SetCapture(hwnd);
let _ = SetFocus(Some(hwnd));
let _ = SetActiveWindow(hwnd);
let _ = EnableWindow(hwnd, true);
let _ = BringWindowToTop(hwnd);
SwitchToThisWindow(hwnd, true);
let _ = AttachThreadInput(dw_current_thread, dw_fg_thread, false);
}
}
/// When restoring focus to the application window, we need a less aggressive method so the electron window doesn't get frozen.
pub(crate) fn restore_focus(hwnd: HWND) {
unsafe {
let _ = SetForegroundWindow(hwnd);
let _ = SetFocus(Some(hwnd));
}
}

View File

@@ -1,13 +1,15 @@
pub mod autofill;
pub mod autostart;
pub mod biometric;
pub mod biometric_v2;
pub mod clipboard;
pub mod crypto;
pub(crate) mod crypto;
pub mod error;
pub mod ipc;
pub mod password;
pub mod powermonitor;
pub mod process_isolation;
pub(crate) mod secure_memory;
pub mod ssh_agent;
use zeroizing_alloc::ZeroAlloc;

View File

@@ -0,0 +1,134 @@
use std::collections::HashMap;
use windows::Win32::Security::Cryptography::{
CryptProtectMemory, CryptUnprotectMemory, CRYPTPROTECTMEMORY_BLOCK_SIZE,
CRYPTPROTECTMEMORY_SAME_PROCESS,
};
use crate::secure_memory::SecureMemoryStore;
/// https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata
/// The DPAPI store encrypts data using the Windows Data Protection API (DPAPI). The key is bound
/// to the current process, and cannot be decrypted by other user-mode processes.
///
/// Note: Admin processes can still decrypt this memory:
/// https://blog.slowerzs.net/posts/cryptdecryptmemory/
pub(crate) struct DpapiSecretKVStore {
map: HashMap<String, Vec<u8>>,
}
impl DpapiSecretKVStore {
pub(crate) fn new() -> Self {
DpapiSecretKVStore {
map: HashMap::new(),
}
}
}
impl SecureMemoryStore for DpapiSecretKVStore {
fn put(&mut self, key: String, value: &[u8]) {
let length_header_len = std::mem::size_of::<usize>();
// The allocated data has to be a multiple of CRYPTPROTECTMEMORY_BLOCK_SIZE, so we pad it and write the length in front
// We are storing LENGTH|DATA|00..00, where LENGTH is the length of DATA, the total length is a multiple
// of CRYPTPROTECTMEMORY_BLOCK_SIZE, and the padding is filled with zeros.
let data_len = value.len();
let len_with_header = data_len + length_header_len;
let padded_length = len_with_header + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize
- (len_with_header % CRYPTPROTECTMEMORY_BLOCK_SIZE as usize);
let mut padded_data = vec![0u8; padded_length];
padded_data[..length_header_len].copy_from_slice(&data_len.to_le_bytes());
padded_data[length_header_len..][..data_len].copy_from_slice(value);
// Protect the memory using DPAPI
unsafe {
CryptProtectMemory(
padded_data.as_mut_ptr() as *mut core::ffi::c_void,
padded_length as u32,
CRYPTPROTECTMEMORY_SAME_PROCESS,
)
}
.expect("crypt_protect_memory should work");
self.map.insert(key, padded_data);
}
fn get(&self, key: &str) -> Option<Vec<u8>> {
self.map.get(key).map(|data| {
// A copy is created, that is then mutated by the DPAPI unprotect function.
let mut data = data.clone();
unsafe {
CryptUnprotectMemory(
data.as_mut_ptr() as *mut core::ffi::c_void,
data.len() as u32,
CRYPTPROTECTMEMORY_SAME_PROCESS,
)
}
.expect("crypt_unprotect_memory should work");
// Unpad the data to retrieve the original value
let length_header_size = std::mem::size_of::<usize>();
let length_bytes = &data[..length_header_size];
let data_length = usize::from_le_bytes(
length_bytes
.try_into()
.expect("length header should be usize"),
);
data[length_header_size..length_header_size + data_length].to_vec()
})
}
fn has(&self, key: &str) -> bool {
self.map.contains_key(key)
}
fn remove(&mut self, key: &str) {
self.map.remove(key);
}
fn clear(&mut self) {
self.map.clear();
}
}
impl Drop for DpapiSecretKVStore {
fn drop(&mut self) {
self.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dpapi_secret_kv_store_various_sizes() {
let mut store = DpapiSecretKVStore::new();
for size in 0..=2048 {
let key = format!("test_key_{}", size);
let value: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
store.put(key.clone(), &value);
assert!(store.has(&key), "Store should have key for size {}", size);
assert_eq!(
store.get(&key),
Some(value),
"Value mismatch for size {}",
size
);
}
}
#[test]
fn test_dpapi_crud() {
let mut store = DpapiSecretKVStore::new();
let key = "test_key".to_string();
let value = vec![1, 2, 3, 4, 5];
store.put(key.clone(), &value);
assert!(store.has(&key));
assert_eq!(store.get(&key), Some(value));
store.remove(&key);
assert!(!store.has(&key));
}
}

View File

@@ -0,0 +1,22 @@
#[cfg(target_os = "windows")]
pub(crate) mod dpapi;
/// The secure memory store provides an ephemeral key-value store for sensitive data.
/// Data stored in this store is prevented from being swapped to disk and zeroed out. Additionally,
/// platform-specific protections are applied to prevent memory dumps or debugger access from
/// reading the stored values.
#[allow(unused)]
pub(crate) trait SecureMemoryStore {
/// Stores a copy of the provided value in secure memory.
fn put(&mut self, key: String, value: &[u8]);
/// Retrieves a copy of the value associated with the given key from secure memory.
/// This copy does not have additional memory protections applied, and should be zeroed when no
/// longer needed.
fn get(&self, key: &str) -> Option<Vec<u8>>;
/// Checks if a value is stored under the given key.
fn has(&self, key: &str) -> bool;
/// Removes the value associated with the given key from secure memory.
fn remove(&mut self, key: &str);
/// Clears all values stored in secure memory.
fn clear(&mut self);
}

View File

@@ -58,6 +58,18 @@ export declare namespace biometrics {
ivB64: string
}
}
export declare namespace biometrics_v2 {
export function initBiometricSystem(): BiometricLockSystem
export function authenticate(biometricLockSystem: BiometricLockSystem, hwnd: Buffer, message: string): Promise<boolean>
export function authenticateAvailable(biometricLockSystem: BiometricLockSystem): Promise<boolean>
export function enrollPersistent(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise<void>
export function provideKey(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise<void>
export function unlock(biometricLockSystem: BiometricLockSystem, userId: string, hwnd: Buffer): Promise<Buffer>
export function unlockAvailable(biometricLockSystem: BiometricLockSystem, userId: string): Promise<boolean>
export function hasPersistent(biometricLockSystem: BiometricLockSystem, userId: string): Promise<boolean>
export function unenroll(biometricLockSystem: BiometricLockSystem, userId: string): Promise<void>
export class BiometricLockSystem { }
}
export declare namespace clipboards {
export function read(): Promise<string>
export function write(text: string, password: boolean): Promise<void>

View File

@@ -149,6 +149,123 @@ pub mod biometrics {
}
}
#[napi]
pub mod biometrics_v2 {
use desktop_core::biometric_v2::BiometricTrait;
#[napi]
pub struct BiometricLockSystem {
inner: desktop_core::biometric_v2::BiometricLockSystem,
}
#[napi]
pub fn init_biometric_system() -> napi::Result<BiometricLockSystem> {
Ok(BiometricLockSystem {
inner: desktop_core::biometric_v2::BiometricLockSystem::new(),
})
}
#[napi]
pub async fn authenticate(
biometric_lock_system: &BiometricLockSystem,
hwnd: napi::bindgen_prelude::Buffer,
message: String,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.authenticate(hwnd.into(), message)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn authenticate_available(
biometric_lock_system: &BiometricLockSystem,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.authenticate_available()
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn enroll_persistent(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
key: napi::bindgen_prelude::Buffer,
) -> napi::Result<()> {
biometric_lock_system
.inner
.enroll_persistent(&user_id, &key)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn provide_key(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
key: napi::bindgen_prelude::Buffer,
) -> napi::Result<()> {
biometric_lock_system
.inner
.provide_key(&user_id, &key)
.await;
Ok(())
}
#[napi]
pub async fn unlock(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
hwnd: napi::bindgen_prelude::Buffer,
) -> napi::Result<napi::bindgen_prelude::Buffer> {
biometric_lock_system
.inner
.unlock(&user_id, hwnd.into())
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
.map(|v| v.into())
}
#[napi]
pub async fn unlock_available(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.unlock_available(&user_id)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn has_persistent(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
) -> napi::Result<bool> {
biometric_lock_system
.inner
.has_persistent(&user_id)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn unenroll(
biometric_lock_system: &BiometricLockSystem,
user_id: String,
) -> napi::Result<()> {
biometric_lock_system
.inner
.unenroll(&user_id)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}
#[napi]
pub mod clipboards {
#[allow(clippy::unused_async)] // FIXME: Remove unused async!

View File

@@ -81,6 +81,31 @@
"additionalTouchIdSettings" | i18n
}}</small>
</div>
<div
class="form-group"
*ngIf="
supportsBiometric &&
form.value.biometric &&
isWindows &&
(userHasMasterPassword || (form.value.pin && userHasPinSet)) &&
isWindowsV2BiometricsEnabled
"
>
<div class="checkbox form-group-child">
<label for="requireMasterPasswordOnAppRestart">
<input
id="requireMasterPasswordOnAppRestart"
type="checkbox"
formControlName="requireMasterPasswordOnAppRestart"
/>
@if (pinEnabled$ | async) {
{{ "requireMasterPasswordOrPinOnAppRestart" | i18n }}
} @else {
{{ "requireMasterPasswordOnAppRestart" | i18n }}
}
</label>
</div>
</div>
<div
class="form-group"
*ngIf="supportsBiometric && this.form.value.biometric && this.isMac"

View File

@@ -30,6 +30,7 @@ import { ValidationService } from "@bitwarden/common/platform/abstractions/valid
import { ThemeType } from "@bitwarden/common/platform/enums";
import { MessageSender } from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
@@ -73,6 +74,9 @@ describe("SettingsComponent", () => {
const desktopAutotypeService = mock<DesktopAutotypeService>();
const billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
const configService = mock<ConfigService>();
const userVerificationService = mock<UserVerificationService>();
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64));
beforeEach(async () => {
jest.clearAllMocks();
@@ -92,6 +96,7 @@ describe("SettingsComponent", () => {
};
i18nService.supportedTranslationLocales = [];
i18nService.t.mockImplementation((key: string) => key);
await TestBed.configureTestingModule({
imports: [],
@@ -124,7 +129,7 @@ describe("SettingsComponent", () => {
{ provide: PolicyService, useValue: policyService },
{ provide: StateService, useValue: mock<StateService>() },
{ provide: ThemeStateService, useValue: themeStateService },
{ provide: UserVerificationService, useValue: mock<UserVerificationService>() },
{ provide: UserVerificationService, useValue: userVerificationService },
{ provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
{ provide: ValidationService, useValue: validationService },
{ provide: MessagingService, useValue: messagingService },
@@ -153,6 +158,7 @@ describe("SettingsComponent", () => {
component = fixture.componentInstance;
fixture.detectChanges();
desktopBiometricsService.hasPersistentKey.mockResolvedValue(false);
vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(
of(VaultTimeoutStringType.OnLocked),
);
@@ -296,43 +302,81 @@ describe("SettingsComponent", () => {
describe("windows desktop", () => {
beforeEach(() => {
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
desktopBiometricsService.isWindowsV2BiometricsEnabled.mockResolvedValue(true);
// Recreate component to apply the correct device
fixture = TestBed.createComponent(SettingsComponent);
component = fixture.componentInstance;
});
it("require password or pin on app start not visible when RemoveUnlockWithPin policy is disabled and pin set and windows desktop", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = false;
policyService.policiesByType$.mockReturnValue(of([policy]));
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
test.each([true, false])(
`correct message display for require MP/PIN on app restart when pin is set, windows desktop, and policy is %s`,
async (policyEnabled) => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = policyEnabled;
policyService.policiesByType$.mockReturnValue(of([policy]));
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit();
fixture.detectChanges();
await component.ngOnInit();
fixture.detectChanges();
const requirePasswordOnStartLabelElement = fixture.debugElement.query(
By.css("label[for='requirePasswordOnStart']"),
);
expect(requirePasswordOnStartLabelElement).toBeNull();
const textNodes = checkRequireMasterPasswordOnAppRestartElement(fixture);
if (policyEnabled) {
expect(textNodes).toContain("requireMasterPasswordOnAppRestart");
} else {
expect(textNodes).toContain("requireMasterPasswordOrPinOnAppRestart");
}
},
);
describe("users without a master password", () => {
beforeEach(() => {
userVerificationService.hasMasterPassword.mockResolvedValue(false);
});
it("displays require MP/PIN on app restart checkbox when pin is set", async () => {
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit();
fixture.detectChanges();
checkRequireMasterPasswordOnAppRestartElement(fixture);
});
it("does not display require MP/PIN on app restart checkbox when pin is not set", async () => {
pinServiceAbstraction.isPinSet.mockResolvedValue(false);
await component.ngOnInit();
fixture.detectChanges();
const requireMasterPasswordOnAppRestartLabelElement = fixture.debugElement.query(
By.css("label[for='requireMasterPasswordOnAppRestart']"),
);
expect(requireMasterPasswordOnAppRestartLabelElement).toBeNull();
});
});
it("require password on app start not visible when RemoveUnlockWithPin policy is enabled and pin set and windows desktop", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = true;
policyService.policiesByType$.mockReturnValue(of([policy]));
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit();
fixture.detectChanges();
const requirePasswordOnStartLabelElement = fixture.debugElement.query(
By.css("label[for='requirePasswordOnStart']"),
function checkRequireMasterPasswordOnAppRestartElement(
fixture: ComponentFixture<SettingsComponent>,
) {
const requireMasterPasswordOnAppRestartLabelElement = fixture.debugElement.query(
By.css("label[for='requireMasterPasswordOnAppRestart']"),
);
expect(requirePasswordOnStartLabelElement).toBeNull();
});
expect(requireMasterPasswordOnAppRestartLabelElement).not.toBeNull();
expect(requireMasterPasswordOnAppRestartLabelElement.children).toHaveLength(1);
expect(requireMasterPasswordOnAppRestartLabelElement.children[0].name).toBe("input");
expect(requireMasterPasswordOnAppRestartLabelElement.children[0].attributes).toMatchObject({
id: "requireMasterPasswordOnAppRestart",
type: "checkbox",
});
const textNodes = requireMasterPasswordOnAppRestartLabelElement.childNodes
.filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE)
.map((node) => node.nativeNode.wholeText?.trim());
return textNodes;
}
});
});
@@ -362,7 +406,7 @@ describe("SettingsComponent", () => {
await component.updatePinHandler(true);
expect(component.form.controls.pin.value).toBe(false);
expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled();
expect(pinServiceAbstraction.unsetPin).not.toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
@@ -378,7 +422,7 @@ describe("SettingsComponent", () => {
await component.updatePinHandler(true);
expect(component.form.controls.pin.value).toBe(dialogResult);
expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled();
expect(pinServiceAbstraction.unsetPin).not.toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
},
);
@@ -390,9 +434,147 @@ describe("SettingsComponent", () => {
await component.updatePinHandler(false);
expect(component.form.controls.pin.value).toBe(false);
expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled();
expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
describe("when windows biometric v2 feature flag is enabled", () => {
beforeEach(() => {
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
});
test.each([false, true])(
"enrolls persistent biometric if needed, enrolled is %s",
async (enrolled) => {
desktopBiometricsService.hasPersistentKey.mockResolvedValue(enrolled);
await component.ngOnInit();
component.isWindowsV2BiometricsEnabled = true;
component.isWindows = true;
component.form.value.requireMasterPasswordOnAppRestart = true;
component.userHasMasterPassword = false;
component.supportsBiometric = true;
component.form.value.biometric = true;
await component.updatePinHandler(false);
expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false);
expect(component.form.controls.pin.value).toBe(false);
expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
if (enrolled) {
expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled();
} else {
expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith(
mockUserId,
mockUserKey,
);
}
},
);
test.each([
{
userHasMasterPassword: true,
supportsBiometric: false,
biometric: false,
requireMasterPasswordOnAppRestart: false,
},
{
userHasMasterPassword: true,
supportsBiometric: false,
biometric: false,
requireMasterPasswordOnAppRestart: true,
},
{
userHasMasterPassword: true,
supportsBiometric: false,
biometric: true,
requireMasterPasswordOnAppRestart: false,
},
{
userHasMasterPassword: true,
supportsBiometric: false,
biometric: true,
requireMasterPasswordOnAppRestart: true,
},
{
userHasMasterPassword: true,
supportsBiometric: true,
biometric: false,
requireMasterPasswordOnAppRestart: false,
},
{
userHasMasterPassword: true,
supportsBiometric: true,
biometric: false,
requireMasterPasswordOnAppRestart: true,
},
{
userHasMasterPassword: false,
supportsBiometric: false,
biometric: false,
requireMasterPasswordOnAppRestart: false,
},
{
userHasMasterPassword: false,
supportsBiometric: false,
biometric: false,
requireMasterPasswordOnAppRestart: true,
},
{
userHasMasterPassword: false,
supportsBiometric: false,
biometric: true,
requireMasterPasswordOnAppRestart: false,
},
{
userHasMasterPassword: false,
supportsBiometric: false,
biometric: true,
requireMasterPasswordOnAppRestart: true,
},
{
userHasMasterPassword: false,
supportsBiometric: true,
biometric: false,
requireMasterPasswordOnAppRestart: false,
},
{
userHasMasterPassword: false,
supportsBiometric: true,
biometric: false,
requireMasterPasswordOnAppRestart: true,
},
])(
"does not enroll persistent biometric when conditions are not met: userHasMasterPassword=$userHasMasterPassword, supportsBiometric=$supportsBiometric, biometric=$biometric, requireMasterPasswordOnAppRestart=$requireMasterPasswordOnAppRestart",
async ({
userHasMasterPassword,
supportsBiometric,
biometric,
requireMasterPasswordOnAppRestart,
}) => {
desktopBiometricsService.hasPersistentKey.mockResolvedValue(false);
await component.ngOnInit();
component.isWindowsV2BiometricsEnabled = true;
component.isWindows = true;
component.form.value.requireMasterPasswordOnAppRestart =
requireMasterPasswordOnAppRestart;
component.userHasMasterPassword = userHasMasterPassword;
component.supportsBiometric = supportsBiometric;
component.form.value.biometric = biometric;
await component.updatePinHandler(false);
expect(component.form.controls.pin.value).toBe(false);
expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled();
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled();
},
);
});
});
});
@@ -474,22 +656,92 @@ describe("SettingsComponent", () => {
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
it("handles windows case", async () => {
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available);
desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue(
BiometricsStatus.Available,
);
describe("windows test cases", () => {
beforeEach(() => {
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
component.isWindows = true;
component.isLinux = false;
component.isWindows = true;
component.isLinux = false;
await component.updateBiometricHandler(true);
desktopBiometricsService.getBiometricsStatus.mockResolvedValue(
BiometricsStatus.Available,
);
desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue(
BiometricsStatus.Available,
);
});
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(component.form.controls.biometric.value).toBe(true);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
it("handles windows case", async () => {
await component.updateBiometricHandler(true);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(component.form.controls.biometric.value).toBe(true);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
describe("when windows v2 biometrics is enabled", () => {
beforeEach(() => {
component.isWindowsV2BiometricsEnabled = true;
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
});
it("when the user doesn't have a master password or a PIN set, allows biometric unlock on app restart", async () => {
component.userHasMasterPassword = false;
component.userHasPinSet = false;
desktopBiometricsService.hasPersistentKey.mockResolvedValue(false);
await component.updateBiometricHandler(true);
expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId);
expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith(
mockUserId,
mockUserKey,
);
expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(component.form.controls.biometric.value).toBe(true);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
test.each([
[true, true],
[true, false],
[false, true],
])(
"when the userHasMasterPassword is %s and userHasPinSet is %s, require master password/PIN on app restart is the default setting",
async (userHasMasterPassword, userHasPinSet) => {
component.userHasMasterPassword = userHasMasterPassword;
component.userHasPinSet = userHasPinSet;
await component.updateBiometricHandler(true);
expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled();
expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(true);
expect(desktopBiometricsService.deleteBiometricUnlockKeyForUser).toHaveBeenCalledWith(
mockUserId,
);
expect(
desktopBiometricsService.setBiometricProtectedUnlockKeyForUser,
).toHaveBeenCalledWith(mockUserId, mockUserKey);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true);
expect(component.form.controls.autoPromptBiometrics.value).toBe(false);
expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(component.form.controls.biometric.value).toBe(true);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
},
);
});
});
it("handles linux case", async () => {
@@ -553,6 +805,57 @@ describe("SettingsComponent", () => {
});
});
describe("updateRequireMasterPasswordOnAppRestartHandler", () => {
beforeEach(() => {
jest.clearAllMocks();
keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey));
});
test.each([true, false])(`handles thrown errors when updated to %s`, async (update) => {
const error = new Error("Test error");
jest.spyOn(component, "updateRequireMasterPasswordOnAppRestart").mockRejectedValue(error);
await component.ngOnInit();
await component.updateRequireMasterPasswordOnAppRestartHandler(update, mockUserId);
expect(logService.error).toHaveBeenCalled();
expect(validationService.showError).toHaveBeenCalledWith(error);
});
describe("when updating to true", () => {
it("calls the biometrics service to clear and reset biometric key", async () => {
await component.ngOnInit();
await component.updateRequireMasterPasswordOnAppRestartHandler(true, mockUserId);
expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId);
expect(desktopBiometricsService.deleteBiometricUnlockKeyForUser).toHaveBeenCalledWith(
mockUserId,
);
expect(desktopBiometricsService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith(
mockUserId,
mockUserKey,
);
});
});
describe("when updating to false", () => {
it("doesn't enroll persistent biometric if already enrolled", async () => {
biometricStateService.hasPersistentKey.mockResolvedValue(false);
await component.ngOnInit();
await component.updateRequireMasterPasswordOnAppRestartHandler(false, mockUserId);
expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId);
expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith(
mockUserId,
mockUserKey,
);
expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false);
});
});
});
describe("saveVaultTimeout", () => {
const DEFAULT_VAULT_TIMEOUT: VaultTimeout = 123;
const DEFAULT_VAULT_TIMEOUT_ACTION = VaultTimeoutAction.Lock;

View File

@@ -142,6 +142,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
userHasPinSet: boolean;
pinEnabled$: Observable<boolean> = of(true);
isWindowsV2BiometricsEnabled: boolean = false;
form = this.formBuilder.group({
// Security
@@ -149,6 +150,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
vaultTimeoutAction: [VaultTimeoutAction.Lock],
pin: [null as boolean | null],
biometric: false,
requireMasterPasswordOnAppRestart: true,
autoPromptBiometrics: false,
// Account Preferences
clearClipboard: [null],
@@ -281,6 +283,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
}
async ngOnInit() {
this.isWindowsV2BiometricsEnabled = await this.biometricsService.isWindowsV2BiometricsEnabled();
this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions();
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
@@ -372,6 +376,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
),
pin: this.userHasPinSet,
biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(),
requireMasterPasswordOnAppRestart: !(await this.biometricsService.hasPersistentKey(
activeAccount.id,
)),
autoPromptBiometrics: await firstValueFrom(this.biometricStateService.promptAutomatically$),
clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$),
minimizeOnCopyToClipboard: await firstValueFrom(this.desktopSettingsService.minimizeOnCopy$),
@@ -479,6 +486,15 @@ export class SettingsComponent implements OnInit, OnDestroy {
)
.subscribe();
this.form.controls.requireMasterPasswordOnAppRestart.valueChanges
.pipe(
concatMap(async (value) => {
await this.updateRequireMasterPasswordOnAppRestartHandler(value, activeAccount.id);
}),
takeUntil(this.destroy$),
)
.subscribe();
this.form.controls.enableBrowserIntegration.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((enabled) => {
@@ -588,6 +604,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.form.controls.pin.setValue(this.userHasPinSet, { emitEvent: false });
} else {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
// On Windows if a user turned off PIN without having a MP and has biometrics + require MP/PIN on restart enabled.
if (
this.isWindows &&
this.isWindowsV2BiometricsEnabled &&
this.supportsBiometric &&
this.form.value.requireMasterPasswordOnAppRestart &&
this.form.value.biometric &&
!this.userHasMasterPassword
) {
// Allow biometric unlock on app restart so the user doesn't get into a bad state.
await this.enrollPersistentBiometricIfNeeded(userId);
}
await this.pinService.unsetPin(userId);
}
}
@@ -639,6 +668,16 @@ export class SettingsComponent implements OnInit, OnDestroy {
// Recommended settings for Windows Hello
this.form.controls.autoPromptBiometrics.setValue(false);
await this.biometricStateService.setPromptAutomatically(false);
if (this.isWindowsV2BiometricsEnabled) {
// If the user doesn't have a MP or PIN then they have to use biometrics on app restart.
if (!this.userHasMasterPassword && !this.userHasPinSet) {
// Allow biometric unlock on app restart so the user doesn't get into a bad state.
await this.enrollPersistentBiometricIfNeeded(activeUserId);
} else {
this.form.controls.requireMasterPasswordOnAppRestart.setValue(true);
}
}
} else if (this.isLinux) {
// Similar to Windows
this.form.controls.autoPromptBiometrics.setValue(false);
@@ -656,6 +695,37 @@ export class SettingsComponent implements OnInit, OnDestroy {
}
}
async updateRequireMasterPasswordOnAppRestartHandler(enabled: boolean, userId: UserId) {
try {
await this.updateRequireMasterPasswordOnAppRestart(enabled, userId);
} catch (error) {
this.logService.error("Error updating require master password on app restart: ", error);
this.validationService.showError(error);
}
}
async updateRequireMasterPasswordOnAppRestart(enabled: boolean, userId: UserId) {
if (enabled) {
// Require master password or PIN on app restart
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
await this.biometricsService.deleteBiometricUnlockKeyForUser(userId);
await this.biometricsService.setBiometricProtectedUnlockKeyForUser(userId, userKey);
} else {
// Allow biometric unlock on app restart
await this.enrollPersistentBiometricIfNeeded(userId);
}
}
private async enrollPersistentBiometricIfNeeded(userId: UserId): Promise<void> {
if (!(await this.biometricsService.hasPersistentKey(userId))) {
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
await this.biometricsService.enrollPersistent(userId, userKey);
this.form.controls.requireMasterPasswordOnAppRestart.setValue(false, {
emitEvent: false,
});
}
}
async updateAutoPromptBiometrics() {
if (this.form.value.autoPromptBiometrics) {
await this.biometricStateService.setPromptAutomatically(true);

View File

@@ -28,6 +28,7 @@ import { DesktopAutotypeService } from "../../autofill/services/desktop-autotype
import { SshAgentService } from "../../autofill/services/ssh-agent.service";
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
import { VersionService } from "../../platform/services/version.service";
import { BiometricMessageHandlerService } from "../../services/biometric-message-handler.service";
import { NativeMessagingService } from "../../services/native-messaging.service";
@Injectable()
@@ -53,6 +54,7 @@ export class InitService {
private autofillService: DesktopAutofillService,
private autotypeService: DesktopAutotypeService,
private sdkLoadService: SdkLoadService,
private biometricMessageHandlerService: BiometricMessageHandlerService,
private configService: ConfigService,
@Inject(DOCUMENT) private document: Document,
private readonly migrationRunner: MigrationRunner,
@@ -95,6 +97,7 @@ export class InitService {
const containerService = new ContainerService(this.keyService, this.encryptService);
containerService.attachToGlobal(this.win);
await this.biometricMessageHandlerService.init();
await this.autofillService.init();
await this.autotypeService.init();
};

View File

@@ -13,4 +13,9 @@ export abstract class DesktopBiometricsService extends BiometricsService {
): Promise<void>;
abstract deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void>;
abstract setupBiometrics(): Promise<void>;
abstract enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void>;
abstract hasPersistentKey(userId: UserId): Promise<boolean>;
/* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */
abstract enableWindowsV2Biometrics(): Promise<void>;
abstract isWindowsV2BiometricsEnabled(): Promise<boolean>;
}

View File

@@ -51,6 +51,17 @@ export class MainBiometricsIPCListener {
return await this.biometricService.setShouldAutopromptNow(message.data as boolean);
case BiometricAction.GetShouldAutoprompt:
return await this.biometricService.getShouldAutopromptNow();
case BiometricAction.HasPersistentKey:
return await this.biometricService.hasPersistentKey(message.userId as UserId);
case BiometricAction.EnrollPersistent:
return await this.biometricService.enrollPersistent(
message.userId as UserId,
SymmetricCryptoKey.fromString(message.key as string),
);
case BiometricAction.EnableWindowsV2:
return await this.biometricService.enableWindowsV2Biometrics();
case BiometricAction.IsWindowsV2Enabled:
return await this.biometricService.isWindowsV2BiometricsEnabled();
default:
return;
}

View File

@@ -7,6 +7,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { newGuid } from "@bitwarden/guid";
import {
BiometricsService,
BiometricsStatus,
@@ -16,6 +17,7 @@ import {
import { WindowMain } from "../../main/window.main";
import { MainBiometricsService } from "./main-biometrics.service";
import { WindowsBiometricsSystem } from "./native-v2";
import OsBiometricsServiceLinux from "./os-biometrics-linux.service";
import OsBiometricsServiceMac from "./os-biometrics-mac.service";
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
@@ -28,6 +30,13 @@ jest.mock("@bitwarden/desktop-napi", () => {
};
});
jest.mock("./native-v2", () => ({
WindowsBiometricsSystem: jest.fn(),
biometrics_v2: {
initBiometricSystem: jest.fn(),
},
}));
const unlockKey = new SymmetricCryptoKey(new Uint8Array(64));
describe("MainBiometricsService", function () {
@@ -38,24 +47,6 @@ describe("MainBiometricsService", function () {
const cryptoFunctionService = mock<CryptoFunctionService>();
const encryptService = mock<EncryptService>();
it("Should call the platformspecific methods", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
const mockService = mock<OsBiometricService>();
(sut as any).osBiometricsService = mockService;
await sut.authenticateBiometric();
expect(mockService.authenticateBiometric).toBeCalled();
});
describe("Should create a platform specific service", function () {
it("Should create a biometrics service specific for Windows", () => {
const sut = new MainBiometricsService(
@@ -207,46 +198,6 @@ describe("MainBiometricsService", function () {
});
});
describe("setupBiometrics", () => {
it("should call the platform specific setup method", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
const osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
await sut.setupBiometrics();
expect(osBiometricsService.runSetup).toHaveBeenCalled();
});
});
describe("authenticateWithBiometrics", () => {
it("should call the platform specific authenticate method", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
const osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
await sut.authenticateWithBiometrics();
expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled();
});
});
describe("unlockWithBiometricsForUser", () => {
let sut: MainBiometricsService;
let osBiometricsService: MockProxy<OsBiometricService>;
@@ -288,55 +239,6 @@ describe("MainBiometricsService", function () {
});
});
describe("setBiometricProtectedUnlockKeyForUser", () => {
let sut: MainBiometricsService;
let osBiometricsService: MockProxy<OsBiometricService>;
beforeEach(() => {
sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
});
it("should call the platform specific setBiometricKey method", async () => {
const userId = "test" as UserId;
await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey);
expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey);
});
});
describe("deleteBiometricUnlockKeyForUser", () => {
it("should call the platform specific deleteBiometricKey method", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
const osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
const userId = "test" as UserId;
await sut.deleteBiometricUnlockKeyForUser(userId);
expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId);
});
});
describe("setShouldAutopromptNow", () => {
let sut: MainBiometricsService;
@@ -386,4 +288,138 @@ describe("MainBiometricsService", function () {
expect(shouldAutoPrompt).toBe(true);
});
});
describe("enableWindowsV2Biometrics", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("enables Windows V2 biometrics when platform is win32 and not already enabled", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
"win32",
biometricStateService,
encryptService,
cryptoFunctionService,
);
await sut.enableWindowsV2Biometrics();
expect(logService.info).toHaveBeenCalledWith(
"[BiometricsMain] Loading native biometrics module v2 for windows",
);
expect(await sut.isWindowsV2BiometricsEnabled()).toBe(true);
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
expect(internalService).toBeInstanceOf(WindowsBiometricsSystem);
});
it("should not enable Windows V2 biometrics when platform is not win32", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
"darwin",
biometricStateService,
encryptService,
cryptoFunctionService,
);
await sut.enableWindowsV2Biometrics();
expect(logService.info).not.toHaveBeenCalled();
expect(await sut.isWindowsV2BiometricsEnabled()).toBe(false);
});
it("should not enable Windows V2 biometrics when already enabled", async () => {
const sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
"win32",
biometricStateService,
encryptService,
cryptoFunctionService,
);
// Enable it first
await sut.enableWindowsV2Biometrics();
// Enable it again
await sut.enableWindowsV2Biometrics();
expect(logService.info).toHaveBeenCalledWith(
"[BiometricsMain] Loading native biometrics module v2 for windows",
);
expect(logService.info).toHaveBeenCalledTimes(1);
expect(await sut.isWindowsV2BiometricsEnabled()).toBe(true);
const internalService = (sut as any).osBiometricsService;
expect(internalService).not.toBeNull();
expect(internalService).toBeInstanceOf(WindowsBiometricsSystem);
});
});
describe("pass through methods that call platform specific osBiometricsService methods", () => {
const userId = newGuid() as UserId;
let sut: MainBiometricsService;
let osBiometricsService: MockProxy<OsBiometricService>;
beforeEach(() => {
sut = new MainBiometricsService(
i18nService,
windowMain,
logService,
process.platform,
biometricStateService,
encryptService,
cryptoFunctionService,
);
osBiometricsService = mock<OsBiometricService>();
(sut as any).osBiometricsService = osBiometricsService;
});
it("calls the platform specific setBiometricKey method", async () => {
await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey);
expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey);
});
it("calls the platform specific enrollPersistent method", async () => {
await sut.enrollPersistent(userId, unlockKey);
expect(osBiometricsService.enrollPersistent).toHaveBeenCalledWith(userId, unlockKey);
});
it("calls the platform specific hasPersistentKey method", async () => {
await sut.hasPersistentKey(userId);
expect(osBiometricsService.hasPersistentKey).toHaveBeenCalledWith(userId);
});
it("calls the platform specific deleteBiometricUnlockKeyForUser method", async () => {
await sut.deleteBiometricUnlockKeyForUser(userId);
expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId);
});
it("calls the platform specific authenticateWithBiometrics method", async () => {
await sut.authenticateWithBiometrics();
expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled();
});
it("calls the platform specific authenticateBiometric method", async () => {
await sut.authenticateBiometric();
expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled();
});
it("calls the platform specific setupBiometrics method", async () => {
await sut.setupBiometrics();
expect(osBiometricsService.runSetup).toHaveBeenCalled();
});
});
});

View File

@@ -10,17 +10,19 @@ import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-manageme
import { WindowMain } from "../../main/window.main";
import { DesktopBiometricsService } from "./desktop.biometrics.service";
import { WindowsBiometricsSystem } from "./native-v2";
import { OsBiometricService } from "./os-biometrics.service";
export class MainBiometricsService extends DesktopBiometricsService {
private osBiometricsService: OsBiometricService;
private shouldAutoPrompt = true;
private windowsV2BiometricsEnabled = false;
constructor(
private i18nService: I18nService,
private windowMain: WindowMain,
private logService: LogService,
platform: NodeJS.Platform,
private platform: NodeJS.Platform,
private biometricStateService: BiometricStateService,
private encryptService: EncryptService,
private cryptoFunctionService: CryptoFunctionService,
@@ -144,4 +146,28 @@ export class MainBiometricsService extends DesktopBiometricsService {
async canEnableBiometricUnlock(): Promise<boolean> {
return true;
}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
return await this.osBiometricsService.enrollPersistent(userId, key);
}
async hasPersistentKey(userId: UserId): Promise<boolean> {
return await this.osBiometricsService.hasPersistentKey(userId);
}
async enableWindowsV2Biometrics(): Promise<void> {
if (this.platform === "win32" && !this.windowsV2BiometricsEnabled) {
this.logService.info("[BiometricsMain] Loading native biometrics module v2 for windows");
this.osBiometricsService = new WindowsBiometricsSystem(
this.i18nService,
this.windowMain,
this.logService,
);
this.windowsV2BiometricsEnabled = true;
}
}
async isWindowsV2BiometricsEnabled(): Promise<boolean> {
return this.windowsV2BiometricsEnabled;
}
}

View File

@@ -0,0 +1 @@
export { default as WindowsBiometricsSystem } from "./os-biometrics-windows.service";

View File

@@ -0,0 +1,126 @@
import { mock } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics_v2 } from "@bitwarden/desktop-napi";
import { BiometricsStatus } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { WindowMain } from "../../main/window.main";
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
jest.mock("@bitwarden/desktop-napi", () => ({
biometrics_v2: {
initBiometricSystem: jest.fn(() => "mockSystem"),
provideKey: jest.fn(),
enrollPersistent: jest.fn(),
unenroll: jest.fn(),
unlock: jest.fn(),
authenticate: jest.fn(),
authenticateAvailable: jest.fn(),
unlockAvailable: jest.fn(),
hasPersistent: jest.fn(),
},
passwords: {
isAvailable: jest.fn(),
},
}));
const mockKey = new Uint8Array(64);
jest.mock("../../../utils", () => ({
isFlatpak: jest.fn(() => false),
isLinux: jest.fn(() => true),
isSnapStore: jest.fn(() => false),
}));
describe("OsBiometricsServiceWindows", () => {
const userId = "user-id" as UserId;
let service: OsBiometricsServiceWindows;
let i18nService: I18nService;
let windowMain: WindowMain;
let logService: LogService;
beforeEach(() => {
i18nService = mock<I18nService>();
windowMain = mock<WindowMain>();
logService = mock<LogService>();
windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(Buffer.from([1, 2, 3, 4]));
service = new OsBiometricsServiceWindows(i18nService, windowMain, logService);
});
it("should enroll persistent biometric key", async () => {
await service.enrollPersistent("user-id" as UserId, new SymmetricCryptoKey(mockKey));
expect(biometrics_v2.enrollPersistent).toHaveBeenCalled();
});
it("should set biometric key", async () => {
await service.setBiometricKey(userId, new SymmetricCryptoKey(mockKey));
expect(biometrics_v2.provideKey).toHaveBeenCalled();
});
it("should delete biometric key", async () => {
await service.deleteBiometricKey(userId);
expect(biometrics_v2.unenroll).toHaveBeenCalled();
});
it("should get biometric key", async () => {
(biometrics_v2.unlock as jest.Mock).mockResolvedValue(mockKey);
const result = await service.getBiometricKey(userId);
expect(result).toBeInstanceOf(SymmetricCryptoKey);
});
it("should return null if no biometric key", async () => {
const error = new Error("No key found");
(biometrics_v2.unlock as jest.Mock).mockRejectedValue(error);
const result = await service.getBiometricKey(userId);
expect(result).toBeNull();
expect(logService.warning).toHaveBeenCalledWith(
`[OsBiometricsServiceWindows] Fetching the biometric key failed: ${error} returning null`,
);
});
it("should authenticate biometric", async () => {
(biometrics_v2.authenticate as jest.Mock).mockResolvedValue(true);
const result = await service.authenticateBiometric();
expect(result).toBe(true);
});
it("should check if biometrics is supported", async () => {
(biometrics_v2.authenticateAvailable as jest.Mock).mockResolvedValue(true);
const result = await service.supportsBiometrics();
expect(result).toBe(true);
});
it("should return needs setup false", async () => {
const result = await service.needsSetup();
expect(result).toBe(false);
});
it("should return auto setup false", async () => {
const result = await service.canAutoSetup();
expect(result).toBe(false);
});
it("should get biometrics first unlock status for user", async () => {
(biometrics_v2.unlockAvailable as jest.Mock).mockResolvedValue(true);
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.Available);
});
it("should return false for hasPersistentKey false", async () => {
(biometrics_v2.hasPersistent as jest.Mock).mockResolvedValue(false);
const result = await service.hasPersistentKey(userId);
expect(result).toBe(false);
});
it("should return false for hasPersistentKey true", async () => {
(biometrics_v2.hasPersistent as jest.Mock).mockResolvedValue(true);
const result = await service.hasPersistentKey(userId);
expect(result).toBe(true);
});
});

View File

@@ -0,0 +1,91 @@
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { biometrics_v2 } from "@bitwarden/desktop-napi";
import { BiometricsStatus } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { WindowMain } from "../../../main/window.main";
import { OsBiometricService } from "../os-biometrics.service";
export default class OsBiometricsServiceWindows implements OsBiometricService {
private biometricsSystem: biometrics_v2.BiometricLockSystem;
constructor(
private i18nService: I18nService,
private windowMain: WindowMain,
private logService: LogService,
) {
this.biometricsSystem = biometrics_v2.initBiometricSystem();
}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
await biometrics_v2.enrollPersistent(
this.biometricsSystem,
userId,
Buffer.from(key.toEncoded().buffer),
);
}
async hasPersistentKey(userId: UserId): Promise<boolean> {
return await biometrics_v2.hasPersistent(this.biometricsSystem, userId);
}
async supportsBiometrics(): Promise<boolean> {
return await biometrics_v2.authenticateAvailable(this.biometricsSystem);
}
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
try {
const key = await biometrics_v2.unlock(
this.biometricsSystem,
userId,
this.windowMain.win.getNativeWindowHandle(),
);
return key ? new SymmetricCryptoKey(Uint8Array.from(key)) : null;
} catch (error) {
this.logService.warning(
`[OsBiometricsServiceWindows] Fetching the biometric key failed: ${error} returning null`,
);
return null;
}
}
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
await biometrics_v2.provideKey(
this.biometricsSystem,
userId,
Buffer.from(key.toEncoded().buffer),
);
}
async deleteBiometricKey(userId: UserId): Promise<void> {
await biometrics_v2.unenroll(this.biometricsSystem, userId);
}
async authenticateBiometric(): Promise<boolean> {
const hwnd = this.windowMain.win.getNativeWindowHandle();
return await biometrics_v2.authenticate(
this.biometricsSystem,
hwnd,
this.i18nService.t("windowsHelloConsentMessage"),
);
}
async needsSetup() {
return false;
}
async canAutoSetup(): Promise<boolean> {
return false;
}
async runSetup(): Promise<void> {}
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
return (await biometrics_v2.hasPersistent(this.biometricsSystem, userId)) ||
(await biometrics_v2.unlockAvailable(this.biometricsSystem, userId))
? BiometricsStatus.Available
: BiometricsStatus.UnlockNeeded;
}
}

View File

@@ -47,6 +47,12 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
private logService: LogService,
) {}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {}
async hasPersistentKey(userId: UserId): Promise<boolean> {
return false;
}
private _iv: string | null = null;
// Use getKeyMaterial helper instead of direct access
private _osKeyHalf: string | null = null;

View File

@@ -20,6 +20,14 @@ export default class OsBiometricsServiceMac implements OsBiometricService {
private logService: LogService,
) {}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
return await passwords.setPassword(SERVICE, getLookupKeyForUser(userId), key.toBase64());
}
async hasPersistentKey(userId: UserId): Promise<boolean> {
return (await passwords.getPassword(SERVICE, getLookupKeyForUser(userId))) != null;
}
async supportsBiometrics(): Promise<boolean> {
return systemPreferences.canPromptTouchID();
}

View File

@@ -35,6 +35,12 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
private cryptoFunctionService: CryptoFunctionService,
) {}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {}
async hasPersistentKey(userId: UserId): Promise<boolean> {
return false;
}
async supportsBiometrics(): Promise<boolean> {
return await biometrics.available();
}

View File

@@ -25,4 +25,6 @@ export interface OsBiometricService {
setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void>;
deleteBiometricKey(userId: UserId): Promise<void>;
getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus>;
enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void>;
hasPersistentKey(userId: UserId): Promise<boolean>;
}

View File

@@ -68,4 +68,20 @@ export class RendererBiometricsService extends DesktopBiometricsService {
BiometricsStatus.ManualSetupNeeded,
].includes(biometricStatus);
}
async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
return await ipc.keyManagement.biometric.enrollPersistent(userId, key.toBase64());
}
async hasPersistentKey(userId: UserId): Promise<boolean> {
return await ipc.keyManagement.biometric.hasPersistentKey(userId);
}
async enableWindowsV2Biometrics(): Promise<void> {
return await ipc.keyManagement.biometric.enableWindowsV2Biometrics();
}
async isWindowsV2BiometricsEnabled(): Promise<boolean> {
return await ipc.keyManagement.biometric.isWindowsV2BiometricsEnabled();
}
}

View File

@@ -50,6 +50,25 @@ const biometric = {
action: BiometricAction.SetShouldAutoprompt,
data: should,
} satisfies BiometricMessage),
enrollPersistent: (userId: string, keyB64: string): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.EnrollPersistent,
userId: userId,
key: keyB64,
} satisfies BiometricMessage),
hasPersistentKey: (userId: string): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.HasPersistentKey,
userId: userId,
} satisfies BiometricMessage),
enableWindowsV2Biometrics: (): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.EnableWindowsV2,
} satisfies BiometricMessage),
isWindowsV2BiometricsEnabled: (): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.IsWindowsV2Enabled,
} satisfies BiometricMessage),
};
export default {

View File

@@ -1852,6 +1852,12 @@
"lockWithMasterPassOnRestart1": {
"message": "Lock with master password on restart"
},
"requireMasterPasswordOrPinOnAppRestart": {
"message": "Require master password or PIN on app restart"
},
"requireMasterPasswordOnAppRestart": {
"message": "Require master password on app restart"
},
"deleteAccount": {
"message": "Delete account"
},

View File

@@ -82,7 +82,12 @@ export class WindowMain {
ipcMain.on("window-hide", () => {
if (this.win != null) {
this.win.hide();
if (isWindows()) {
// On windows, to return focus we need minimize
this.win.minimize();
} else {
this.win.hide();
}
}
});

View File

@@ -13,13 +13,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService, I18nMockService } from "@bitwarden/components";
import {
KeyService,
BiometricsService,
BiometricStateService,
BiometricsCommands,
} from "@bitwarden/key-management";
import { DialogService } from "@bitwarden/components";
import { KeyService, BiometricsService, BiometricsCommands } from "@bitwarden/key-management";
import { ConfigService } from "@bitwarden/services/config.service";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
@@ -47,15 +43,14 @@ describe("BiometricMessageHandlerService", () => {
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let logService: MockProxy<LogService>;
let configService: MockProxy<ConfigService>;
let messagingService: MockProxy<MessagingService>;
let desktopSettingsService: DesktopSettingsService;
let biometricStateService: BiometricStateService;
let biometricsService: MockProxy<BiometricsService>;
let dialogService: MockProxy<DialogService>;
let accountService: AccountService;
let authService: MockProxy<AuthService>;
let ngZone: MockProxy<NgZone>;
let i18nService: MockProxy<I18nMockService>;
beforeEach(() => {
cryptoFunctionService = mock<CryptoFunctionService>();
@@ -64,14 +59,13 @@ describe("BiometricMessageHandlerService", () => {
logService = mock<LogService>();
messagingService = mock<MessagingService>();
desktopSettingsService = mock<DesktopSettingsService>();
biometricStateService = mock<BiometricStateService>();
configService = mock<ConfigService>();
biometricsService = mock<BiometricsService>();
dialogService = mock<DialogService>();
accountService = new FakeAccountService(accounts);
authService = mock<AuthService>();
ngZone = mock<NgZone>();
i18nService = mock<I18nMockService>();
desktopSettingsService.browserIntegrationEnabled$ = of(false);
desktopSettingsService.browserIntegrationFingerprintEnabled$ = of(false);
@@ -94,7 +88,7 @@ describe("BiometricMessageHandlerService", () => {
cryptoFunctionService.rsaEncrypt.mockResolvedValue(
Utils.fromUtf8ToArray("encrypted") as CsprngArray,
);
configService.getFeatureFlag.mockResolvedValue(false);
service = new BiometricMessageHandlerService(
cryptoFunctionService,
keyService,
@@ -102,13 +96,12 @@ describe("BiometricMessageHandlerService", () => {
logService,
messagingService,
desktopSettingsService,
biometricStateService,
biometricsService,
dialogService,
accountService,
authService,
ngZone,
i18nService,
configService,
);
});
@@ -160,13 +153,12 @@ describe("BiometricMessageHandlerService", () => {
logService,
messagingService,
desktopSettingsService,
biometricStateService,
biometricsService,
dialogService,
accountService,
authService,
ngZone,
i18nService,
configService,
);
});
@@ -511,4 +503,19 @@ describe("BiometricMessageHandlerService", () => {
},
);
});
describe("init", () => {
it("enables Windows v2 biometrics when feature flag enabled", async () => {
configService.getFeatureFlag.mockReturnValue(true);
await service.init();
expect(biometricsService.enableWindowsV2Biometrics).toHaveBeenCalled();
});
it("does not enable Windows v2 biometrics when feature flag disabled", async () => {
configService.getFeatureFlag.mockReturnValue(false);
await service.init();
expect(biometricsService.enableWindowsV2Biometrics).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,25 +4,21 @@ import { combineLatest, concatMap, firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import {
BiometricStateService,
BiometricsCommands,
BiometricsService,
BiometricsStatus,
KeyService,
} from "@bitwarden/key-management";
import { BiometricsCommands, BiometricsStatus, KeyService } from "@bitwarden/key-management";
import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component";
import { DesktopBiometricsService } from "../key-management/biometrics/desktop.biometrics.service";
import { LegacyMessage, LegacyMessageWrapper } from "../models/native-messaging";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
@@ -82,13 +78,12 @@ export class BiometricMessageHandlerService {
private logService: LogService,
private messagingService: MessagingService,
private desktopSettingService: DesktopSettingsService,
private biometricStateService: BiometricStateService,
private biometricsService: BiometricsService,
private biometricsService: DesktopBiometricsService,
private dialogService: DialogService,
private accountService: AccountService,
private authService: AuthService,
private ngZone: NgZone,
private i18nService: I18nService,
private configService: ConfigService,
) {
combineLatest([
this.desktopSettingService.browserIntegrationEnabled$,
@@ -119,6 +114,19 @@ export class BiometricMessageHandlerService {
private connectedApps: ConnectedApps = new ConnectedApps();
async init() {
this.logService.debug(
"[BiometricMessageHandlerService] Initializing biometric message handler",
);
const windowsV2Enabled = await this.configService.getFeatureFlag(
FeatureFlag.WindowsBiometricsV2,
);
if (windowsV2Enabled) {
await this.biometricsService.enableWindowsV2Biometrics();
}
}
async handleMessage(msg: LegacyMessageWrapper) {
const { appId, message: rawMessage } = msg as LegacyMessageWrapper;

View File

@@ -13,6 +13,12 @@ export enum BiometricAction {
GetShouldAutoprompt = "getShouldAutoprompt",
SetShouldAutoprompt = "setShouldAutoprompt",
EnrollPersistent = "enrollPersistent",
HasPersistentKey = "hasPersistentKey",
EnableWindowsV2 = "enableWindowsV2",
IsWindowsV2Enabled = "isWindowsV2Enabled",
}
export type BiometricMessage =
@@ -22,7 +28,15 @@ export type BiometricMessage =
key: string;
}
| {
action: Exclude<BiometricAction, BiometricAction.SetKeyForUser>;
action: BiometricAction.EnrollPersistent;
userId: string;
key: string;
}
| {
action: Exclude<
BiometricAction,
BiometricAction.SetKeyForUser | BiometricAction.EnrollPersistent
>;
userId?: string;
data?: any;
};

View File

@@ -35,6 +35,7 @@ export enum FeatureFlag {
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2",
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
/* Tools */
@@ -115,6 +116,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
[FeatureFlag.WindowsBiometricsV2]: FALSE,
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
/* Platform */