mirror of
https://github.com/bitwarden/browser
synced 2026-02-05 11:13:44 +00:00
Add feature flag and re-add old impl
This commit is contained in:
38
apps/desktop/desktop_native/core/src/biometric/macos.rs
Normal file
38
apps/desktop/desktop_native/core/src/biometric/macos.rs
Normal file
@@ -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<u8>, _message: String) -> Result<bool> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
async fn available() -> Result<bool> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
fn derive_key_material(_iv_str: Option<&str>) -> Result<OsDerivedKey> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
async fn get_biometric_secret(
|
||||
_service: &str,
|
||||
_account: &str,
|
||||
_key_material: Option<KeyMaterial>,
|
||||
) -> Result<String> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
|
||||
async fn set_biometric_secret(
|
||||
_service: &str,
|
||||
_account: &str,
|
||||
_secret: &str,
|
||||
_key_material: Option<super::KeyMaterial>,
|
||||
_iv_b64: &str,
|
||||
) -> Result<String> {
|
||||
bail!("platform not supported");
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<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 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<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>;
|
||||
async fn prompt(hwnd: Vec<u8>, message: String) -> Result<bool>;
|
||||
async fn available() -> Result<bool>;
|
||||
fn derive_key_material(secret: Option<&str>) -> Result<OsDerivedKey>;
|
||||
async fn set_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
secret: &str,
|
||||
key_material: Option<KeyMaterial>,
|
||||
iv_b64: &str,
|
||||
) -> Result<String>;
|
||||
async fn get_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
key_material: Option<KeyMaterial>,
|
||||
) -> Result<String>;
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result<String> {
|
||||
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<String> {
|
||||
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<GenericArray<u8, typenum::U32>> {
|
||||
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::<CipherString>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
109
apps/desktop/desktop_native/core/src/biometric/unix.rs
Normal file
109
apps/desktop/desktop_native/core/src/biometric/unix.rs
Normal file
@@ -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<u8>, _message: String) -> Result<bool> {
|
||||
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<bool> {
|
||||
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<OsDerivedKey> {
|
||||
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<KeyMaterial>,
|
||||
iv_b64: &str,
|
||||
) -> Result<String> {
|
||||
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<KeyMaterial>,
|
||||
) -> Result<String> {
|
||||
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
|
||||
}
|
||||
@@ -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<u8>,
|
||||
}
|
||||
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<Mutex<crate::secure_memory::dpapi::DpapiSecretKVStore>>,
|
||||
}
|
||||
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<u8>, message: String) -> Result<bool> {
|
||||
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<u8>, message: String) -> Result<bool> {
|
||||
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<bool> {
|
||||
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::<UserConsentVerifier, IUserConsentVerifierInterop>()?;
|
||||
let operation: IAsyncOperation<UserConsentVerificationResult> = 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<bool> {
|
||||
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<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 {
|
||||
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<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))
|
||||
fn derive_key_material(challenge_str: Option<&str>) -> Result<OsDerivedKey> {
|
||||
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<bool> {
|
||||
Ok(get_keychain_entry(user_id).await.is_ok())
|
||||
async fn set_biometric_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
secret: &str,
|
||||
key_material: Option<KeyMaterial>,
|
||||
iv_b64: &str,
|
||||
) -> Result<String> {
|
||||
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<bool> {
|
||||
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<KeyMaterial>,
|
||||
) -> Result<String> {
|
||||
let key_material = key_material.ok_or(anyhow!(
|
||||
"Key material is required for Windows Hello protected keys"
|
||||
))?;
|
||||
|
||||
let userconsent_verifier = factory::<UserConsentVerifier, IUserConsentVerifierInterop>()?;
|
||||
let userconsent_result: IAsyncOperation<UserConsentVerificationResult> = 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::<u8>::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<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
|
||||
}
|
||||
|
||||
async fn has_keychain_entry(user_id: &str) -> Result<bool> {
|
||||
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>, [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)
|
||||
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 = <Biometric as BiometricTrait>::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 = <Biometric as BiometricTrait>::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() {
|
||||
<Biometric as BiometricTrait>::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!(<Biometric as BiometricTrait>::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 = <Biometric as BiometricTrait>::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 =
|
||||
<Biometric as BiometricTrait>::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 =
|
||||
<Biometric as BiometricTrait>::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 =
|
||||
<Biometric as BiometricTrait>::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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
33
apps/desktop/desktop_native/core/src/biometric_v2/mod.rs
Normal file
33
apps/desktop/desktop_native/core/src/biometric_v2/mod.rs
Normal file
@@ -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<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 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<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>;
|
||||
}
|
||||
412
apps/desktop/desktop_native/core/src/biometric_v2/windows.rs
Normal file
412
apps/desktop/desktop_native/core/src/biometric_v2/windows.rs
Normal file
@@ -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<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)
|
||||
}
|
||||
|
||||
async fn authenticate_available(&self) -> Result<bool> {
|
||||
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<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 {
|
||||
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<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
|
||||
fn windows_hello_authenticate(message: String) -> Result<bool> {
|
||||
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::<UserConsentVerifier, IUserConsentVerifierInterop>()?;
|
||||
let userconsent_result: IAsyncOperation<UserConsentVerificationResult> = 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::<u8>::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<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
|
||||
}
|
||||
|
||||
async fn has_keychain_entry(user_id: &str) -> Result<bool> {
|
||||
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>, [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)
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
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);
|
||||
}
|
||||
}
|
||||
212
apps/desktop/desktop_native/core/src/crypto/cipher_string.rs
Normal file
212
apps/desktop/desktop_native/core/src/crypto/cipher_string.rs
Normal file
@@ -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<u8>,
|
||||
},
|
||||
// 1
|
||||
AesCbc128_HmacSha256_B64 {
|
||||
iv: [u8; 16],
|
||||
mac: [u8; 32],
|
||||
data: Vec<u8>,
|
||||
},
|
||||
// 2
|
||||
AesCbc256_HmacSha256_B64 {
|
||||
iv: [u8; 16],
|
||||
mac: [u8; 32],
|
||||
data: Vec<u8>,
|
||||
},
|
||||
// 3
|
||||
Rsa2048_OaepSha256_B64 {
|
||||
data: Vec<u8>,
|
||||
},
|
||||
// 4
|
||||
Rsa2048_OaepSha1_B64 {
|
||||
data: Vec<u8>,
|
||||
},
|
||||
// 5
|
||||
Rsa2048_OaepSha256_HmacSha256_B64 {
|
||||
mac: [u8; 32],
|
||||
data: Vec<u8>,
|
||||
},
|
||||
// 6
|
||||
Rsa2048_OaepSha1_HmacSha256_B64 {
|
||||
mac: [u8; 32],
|
||||
data: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
// 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<u8>) -> CSParseError {
|
||||
move |e: Vec<_>| CSParseError::InvalidBase64Length {
|
||||
expected,
|
||||
got: e.len(),
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CipherString {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
35
apps/desktop/desktop_native/core/src/crypto/crypto.rs
Normal file
35
apps/desktop/desktop_native/core/src/crypto/crypto.rs
Normal file
@@ -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<u8, U32>) -> Result<Vec<u8>> {
|
||||
let iv = GenericArray::from_slice(iv);
|
||||
let mut data = data.to_vec();
|
||||
let decrypted_key_slice = cbc::Decryptor::<aes::Aes256>::new(&key, iv)
|
||||
.decrypt_padded_mut::<Pkcs7>(&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<u8, U32>,
|
||||
) -> Result<CipherString> {
|
||||
let data = cbc::Encryptor::<aes::Aes256>::new(&key, &iv.into())
|
||||
.encrypt_padded_vec_mut::<Pkcs7>(data_dec);
|
||||
|
||||
Ok(CipherString::AesCbc256_B64 { iv, data })
|
||||
}
|
||||
8
apps/desktop/desktop_native/core/src/crypto/mod.rs
Normal file
8
apps/desktop/desktop_native/core/src/crypto/mod.rs
Normal file
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
32
apps/desktop/desktop_native/napi/index.d.ts
vendored
32
apps/desktop/desktop_native/napi/index.d.ts
vendored
@@ -22,6 +22,34 @@ export declare namespace passwords {
|
||||
export function isAvailable(): Promise<boolean>
|
||||
}
|
||||
export declare namespace biometrics {
|
||||
export function prompt(hwnd: Buffer, message: string): Promise<boolean>
|
||||
export function available(): Promise<boolean>
|
||||
export function setBiometricSecret(service: string, account: string, secret: string, keyMaterial: KeyMaterial | undefined | null, ivB64: string): Promise<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.
|
||||
*/
|
||||
export function getBiometricSecret(service: string, account: string, keyMaterial?: KeyMaterial | undefined | null): Promise<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!("<key_base64>|<iv_base64>")`
|
||||
*/
|
||||
export function deriveKeyMaterial(iv?: string | undefined | null): Promise<OsDerivedKey>
|
||||
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<boolean>
|
||||
export function authenticateAvailable(biometricLockSystem: BiometricLockSystem): Promise<boolean>
|
||||
@@ -212,8 +240,8 @@ export declare namespace chromium_importer {
|
||||
login?: Login
|
||||
failure?: LoginImportFailure
|
||||
}
|
||||
export function getInstalledBrowsers(): Promise<Array<string>>
|
||||
export function getAvailableProfiles(browser: string): Promise<Array<ProfileInfo>>
|
||||
export function getInstalledBrowsers(): Array<string>
|
||||
export function getAvailableProfiles(browser: string): Array<ProfileInfo>
|
||||
export function importLogins(browser: string, profileId: string): Promise<Array<LoginImportResult>>
|
||||
}
|
||||
export declare namespace autotype {
|
||||
|
||||
@@ -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<bool> {
|
||||
Biometric::prompt(hwnd.into(), message)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn available() -> napi::Result<bool> {
|
||||
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<KeyMaterial>,
|
||||
iv_b64: String,
|
||||
) -> napi::Result<String> {
|
||||
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<KeyMaterial>,
|
||||
) -> napi::Result<String> {
|
||||
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!("<key_base64>|<iv_base64>")`
|
||||
#[allow(clippy::unused_async)] // FIXME: Remove unused async!
|
||||
#[napi]
|
||||
pub async fn derive_key_material(iv: Option<String>) -> napi::Result<OsDerivedKey> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
impl From<KeyMaterial> 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<desktop_core::biometric::OsDerivedKey> 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<BiometricLockSystem> {
|
||||
Ok(BiometricLockSystem {
|
||||
inner: desktop_core::biometric::BiometricLockSystem::new(),
|
||||
inner: desktop_core::biometric_v2::BiometricLockSystem::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -15,4 +15,6 @@ export abstract class DesktopBiometricsService extends BiometricsService {
|
||||
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 enableV2BiometricsBackend(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
return await this.osBiometricsService.hasPersistentKey(userId);
|
||||
}
|
||||
|
||||
async enableV2BiometricsBackend(): Promise<void> {
|
||||
this.loadNativeBiometricsModuleV2();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BiometricStateService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
logService = mock<LogService>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC
|
||||
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
|
||||
|
||||
<policyconfig>
|
||||
<action id="com.bitwarden.Bitwarden.unlock">
|
||||
<description>Unlock Bitwarden</description>
|
||||
<message>Authenticate to unlock Bitwarden</message>
|
||||
<defaults>
|
||||
<allow_any>no</allow_any>
|
||||
<allow_inactive>no</allow_inactive>
|
||||
<allow_active>auth_self</allow_active>
|
||||
</defaults>
|
||||
</action>
|
||||
</policyconfig>`;
|
||||
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<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;
|
||||
private clientKeyHalves = new Map<UserId, Uint8Array | null>();
|
||||
|
||||
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
|
||||
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<void> {
|
||||
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<SymmetricCryptoKey | null> {
|
||||
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<boolean> {
|
||||
const hwnd = Buffer.from("");
|
||||
return await biometrics.prompt(hwnd, "");
|
||||
}
|
||||
|
||||
async supportsBiometrics(): Promise<boolean> {
|
||||
// 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<boolean> {
|
||||
if (isSnapStore()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check whether the polkit policy is loaded via dbus call to polkit
|
||||
return !(await biometrics.available());
|
||||
}
|
||||
|
||||
async canAutoSetup(): Promise<boolean> {
|
||||
// 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<void> {
|
||||
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<Uint8Array | null> {
|
||||
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<BiometricsStatus> {
|
||||
if (this.clientKeyHalves.has(userId)) {
|
||||
return BiometricsStatus.Available;
|
||||
} else {
|
||||
return BiometricsStatus.UnlockNeeded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
await this.setBiometricKey(userId, key);
|
||||
}
|
||||
|
||||
async hasPersistentKey(userId: UserId): Promise<boolean> {
|
||||
try {
|
||||
await passwords.getPassword(SERVICE, getLookupKeyForUser(userId));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async supportsBiometrics(): Promise<boolean> {
|
||||
return systemPreferences.canPromptTouchID();
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
try {
|
||||
await systemPreferences.promptTouchID(this.i18nservice.t("touchIdConsentMessage"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
|
||||
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<void> {
|
||||
if (await this.valueUpToDate(userId, key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return await passwords.setPassword(SERVICE, getLookupKeyForUser(userId), key.toBase64());
|
||||
}
|
||||
|
||||
async deleteBiometricKey(user: UserId): Promise<void> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const existing = await passwords.getPassword(SERVICE, getLookupKeyForUser(user));
|
||||
return existing === key.toBase64();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async needsSetup() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async canAutoSetup(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async runSetup(): Promise<void> {}
|
||||
|
||||
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
|
||||
return BiometricsStatus.Available;
|
||||
}
|
||||
}
|
||||
@@ -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<I18nService>();
|
||||
const windowMain = mock<WindowMain>();
|
||||
const browserWindow = mock<BrowserWindow>();
|
||||
const encryptionService: EncryptService = mock<EncryptService>();
|
||||
const cryptoFunctionService: CryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const biometricStateService: BiometricStateService = mock<BiometricStateService>();
|
||||
const logService = mock<LogService>();
|
||||
|
||||
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<string, Uint8Array>();
|
||||
(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<string, Uint8Array>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<UserId, Uint8Array>();
|
||||
|
||||
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<void> {}
|
||||
|
||||
async hasPersistentKey(userId: UserId): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async supportsBiometrics(): Promise<boolean> {
|
||||
return await biometrics.available();
|
||||
}
|
||||
|
||||
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async runSetup(): Promise<void> {}
|
||||
|
||||
async getOrCreateBiometricEncryptionClientKeyHalf(
|
||||
userId: UserId,
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<Uint8Array> {
|
||||
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<BiometricsStatus> {
|
||||
if (this.clientKeyHalves.has(userId)) {
|
||||
return BiometricsStatus.Available;
|
||||
} else {
|
||||
return BiometricsStatus.UnlockNeeded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await biometrics.unenroll(this.biometricsSystem, userId);
|
||||
await biometrics_v2.unenroll(this.biometricsSystem, userId);
|
||||
}
|
||||
|
||||
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
@@ -100,7 +106,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
|
||||
}
|
||||
|
||||
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
|
||||
return (await biometrics.unlockAvailable(this.biometricsSystem, userId))
|
||||
return (await biometrics_v2.unlockAvailable(this.biometricsSystem, userId))
|
||||
? BiometricsStatus.Available
|
||||
: BiometricsStatus.UnlockNeeded;
|
||||
}
|
||||
@@ -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<I18nService>();
|
||||
logService = mock<LogService>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
return await biometrics.hasPersistent(this.biometricsSystem, userId);
|
||||
return await biometrics_v2.hasPersistent(this.biometricsSystem, userId);
|
||||
}
|
||||
|
||||
async supportsBiometrics(): Promise<boolean> {
|
||||
return await biometrics.authenticateAvailable(this.biometricsSystem);
|
||||
return await biometrics_v2.authenticateAvailable(this.biometricsSystem);
|
||||
}
|
||||
|
||||
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await biometrics.unenroll(this.biometricsSystem, userId);
|
||||
await biometrics_v2.unenroll(this.biometricsSystem, userId);
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
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<void> {}
|
||||
|
||||
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
|
||||
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;
|
||||
}
|
||||
@@ -76,4 +76,8 @@ export class RendererBiometricsService extends DesktopBiometricsService {
|
||||
async hasPersistentKey(userId: UserId): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.hasPersistentKey(userId);
|
||||
}
|
||||
|
||||
async enableV2BiometricsBackend(): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.enableBiometricsV2();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ const biometric = {
|
||||
action: BiometricAction.HasPersistentKey,
|
||||
userId: userId,
|
||||
} satisfies BiometricMessage),
|
||||
enableBiometricsV2: (): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.EnableV2,
|
||||
} satisfies BiometricMessage),
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -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$,
|
||||
|
||||
@@ -16,6 +16,8 @@ export enum BiometricAction {
|
||||
|
||||
EnrollPersistent = "enrollPersistent",
|
||||
HasPersistentKey = "hasPersistentKey",
|
||||
|
||||
EnableV2 = "enableV2",
|
||||
}
|
||||
|
||||
export type BiometricMessage =
|
||||
|
||||
@@ -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,
|
||||
|
||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user