1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

Merge branch 'main' into PM-26250-Explore-options-to-enable-direct-importer-for-mac-app-store-build

This commit is contained in:
John Harrington
2025-12-02 07:18:26 -07:00
committed by GitHub
229 changed files with 5088 additions and 5185 deletions

View File

@@ -1864,9 +1864,9 @@ dependencies = [
[[package]]
name = "mockall"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2"
checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b"
dependencies = [
"cfg-if",
"downcast",
@@ -1878,9 +1878,9 @@ dependencies = [
[[package]]
name = "mockall_derive"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898"
checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf"
dependencies = [
"cfg-if",
"proc-macro2",

View File

@@ -9,7 +9,7 @@ publish.workspace = true
anyhow = { workspace = true }
[target.'cfg(windows)'.dependencies]
mockall = "=0.13.1"
mockall = "=0.14.0"
serial_test = "=3.2.0"
tracing.workspace = true
windows = { workspace = true, features = [

View File

@@ -272,6 +272,7 @@ mod tests {
#[serial]
fn send_input_succeeds() {
let ctxi = MockInputOperations::send_input_context();
ctxi.checkpoint();
ctxi.expect().returning(|_| 1);
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
@@ -279,6 +280,8 @@ mod tests {
0,
)])
.unwrap();
drop(ctxi);
}
#[test]
@@ -288,9 +291,11 @@ mod tests {
)]
fn send_input_fails_sent_zero() {
let ctxi = MockInputOperations::send_input_context();
ctxi.checkpoint();
ctxi.expect().returning(|_| 0);
let ctxge = MockErrorOperations::get_last_error_context();
ctxge.checkpoint();
ctxge.expect().returning(|| WIN32_ERROR(1));
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
@@ -298,6 +303,9 @@ mod tests {
0,
)])
.unwrap();
drop(ctxge);
drop(ctxi);
}
#[test]
@@ -305,9 +313,11 @@ mod tests {
#[should_panic(expected = "SendInput does not match expected. sent: 2, expected: 1")]
fn send_input_fails_sent_mismatch() {
let ctxi = MockInputOperations::send_input_context();
ctxi.checkpoint();
ctxi.expect().returning(|_| 2);
let ctxge = MockErrorOperations::get_last_error_context();
ctxge.checkpoint();
ctxge.expect().returning(|| WIN32_ERROR(1));
send_input::<MockInputOperations, MockErrorOperations>(vec![build_unicode_input(
@@ -315,5 +325,8 @@ mod tests {
0,
)])
.unwrap();
drop(ctxge);
drop(ctxi);
}
}

View File

@@ -186,6 +186,7 @@ mod tests {
let mut mock_handle = MockWindowHandleOperations::new();
let ctxse = MockErrorOperations::set_last_error_context();
ctxse.checkpoint();
ctxse
.expect()
.once()
@@ -198,6 +199,7 @@ mod tests {
.returning(|| Ok(0));
let ctxge = MockErrorOperations::get_last_error_context();
ctxge.checkpoint();
ctxge.expect().returning(|| WIN32_ERROR(0));
let len = get_window_title_length::<MockWindowHandleOperations, MockErrorOperations>(
@@ -206,6 +208,9 @@ mod tests {
.unwrap();
assert_eq!(len, 0);
drop(ctxge);
drop(ctxse);
}
#[test]
@@ -215,6 +220,7 @@ mod tests {
let mut mock_handle = MockWindowHandleOperations::new();
let ctxse = MockErrorOperations::set_last_error_context();
ctxse.checkpoint();
ctxse.expect().with(predicate::eq(0)).returning(|_| {});
mock_handle
@@ -223,13 +229,18 @@ mod tests {
.returning(|| Ok(0));
let ctxge = MockErrorOperations::get_last_error_context();
ctxge.checkpoint();
ctxge.expect().returning(|| WIN32_ERROR(1));
get_window_title_length::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle)
.unwrap();
drop(ctxge);
drop(ctxse);
}
#[test]
#[serial]
fn get_window_title_succeeds() {
let mut mock_handle = MockWindowHandleOperations::new();
@@ -246,11 +257,11 @@ mod tests {
.unwrap();
assert_eq!(title.len(), 43); // That extra slot in the buffer for null char
assert_eq!(title, "*******************************************");
}
#[test]
#[serial]
fn get_window_title_returns_empty_string() {
let mock_handle = MockWindowHandleOperations::new();
@@ -273,10 +284,13 @@ mod tests {
.returning(|_| Ok(0));
let ctxge = MockErrorOperations::get_last_error_context();
ctxge.checkpoint();
ctxge.expect().returning(|| WIN32_ERROR(1));
get_window_title::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle, 42)
.unwrap();
drop(ctxge);
}
#[test]
@@ -290,9 +304,12 @@ mod tests {
.returning(|_| Ok(0));
let ctxge = MockErrorOperations::get_last_error_context();
ctxge.checkpoint();
ctxge.expect().returning(|| WIN32_ERROR(0));
get_window_title::<MockWindowHandleOperations, MockErrorOperations>(&mock_handle, 42)
.unwrap();
drop(ctxge);
}
}

View File

@@ -108,7 +108,7 @@ mod tests {
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.iter().any(|l| *l == "file"));
assert!(meta.loaders.contains(&"file"));
}
}
@@ -148,7 +148,7 @@ mod tests {
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.iter().any(|l| *l == "file"));
assert!(meta.loaders.contains(&"file"));
}
}
@@ -184,7 +184,7 @@ mod tests {
for (key, meta) in map.iter() {
assert_eq!(&meta.id, key);
assert_eq!(meta.instructions, "chromium");
assert!(meta.loaders.iter().any(|l| *l == "file"));
assert!(meta.loaders.contains(&"file"));
}
}

View File

@@ -3,16 +3,12 @@ use anyhow::{anyhow, Result};
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "unix.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "unimplemented.rs")]
#[cfg_attr(target_os = "windows", path = "unimplemented.rs")]
mod biometric;
pub use biometric::Biometric;
#[cfg(target_os = "windows")]
pub mod windows_focus;
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
pub use biometric::Biometric;
use sha2::{Digest, Sha256};
use crate::crypto::{self, CipherString};

View File

@@ -2,7 +2,7 @@ use anyhow::{bail, Result};
use crate::biometric::{KeyMaterial, OsDerivedKey};
/// The MacOS implementation of the biometric trait.
/// Unimplemented stub for unsupported platforms
pub struct Biometric {}
impl super::BiometricTrait for Biometric {

View File

@@ -1,240 +0,0 @@
use std::{ffi::c_void, str::FromStr};
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
use rand::RngCore;
use sha2::{Digest, Sha256};
use windows::{
core::{factory, HSTRING},
Security::Credentials::UI::{
UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability,
},
Win32::{
Foundation::HWND, System::WinRT::IUserConsentVerifierInterop,
UI::WindowsAndMessaging::GetForegroundWindow,
},
};
use windows_future::IAsyncOperation;
use super::{decrypt, encrypt, windows_focus::set_focus};
use crate::{
biometric::{KeyMaterial, OsDerivedKey},
crypto::CipherString,
};
/// The Windows OS implementation of the biometric trait.
pub struct Biometric {}
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());
let h = h as *mut c_void;
let window = HWND(h);
// The Windows Hello prompt is displayed inside the application window. For best result we
// should set the window to the foreground and focus it.
set_focus(window);
// Windows Hello prompt must be in foreground, focused, otherwise the face or fingerprint
// unlock will not work. We get the current foreground window, which will either be the
// Bitwarden desktop app or the browser extension.
let foreground_window = unsafe { GetForegroundWindow() };
let interop = factory::<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 available() -> Result<bool> {
let ucv_available = UserConsentVerifier::CheckAvailabilityAsync()?.get()?;
match ucv_available {
UserConsentVerifierAvailability::Available => Ok(true),
// TODO: look into removing this and making the check more ad-hoc
UserConsentVerifierAvailability::DeviceBusy => 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(),
};
// 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 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)
}
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 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)
}
}
}
}
fn random_challenge() -> [u8; 16] {
let mut challenge = [0u8; 16];
rand::rng().fill_bytes(&mut challenge);
challenge
}
#[cfg(test)]
mod tests {
use super::*;
use crate::biometric::BiometricTrait;
#[test]
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]
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]
#[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();
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn test_available() {
assert!(<Biometric as BiometricTrait>::available().await.unwrap())
}
#[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();
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]
#[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();
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"
);
}
}

View File

@@ -1,28 +0,0 @@
use windows::{
core::s,
Win32::{
Foundation::HWND,
UI::{
Input::KeyboardAndMouse::SetFocus,
WindowsAndMessaging::{FindWindowA, SetForegroundWindow},
},
},
};
/// Searches for a window that looks like a security prompt and set it as focused.
/// Only works when the process has permission to foreground, either by being in foreground
/// Or by being given foreground permission https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks
pub fn focus_security_prompt() {
let class_name = s!("Credential Dialog Xaml Host");
let hwnd = unsafe { FindWindowA(class_name, None) };
if let Ok(hwnd) = hwnd {
set_focus(hwnd);
}
}
pub(crate) fn set_focus(window: HWND) {
unsafe {
let _ = SetForegroundWindow(window);
let _ = SetFocus(Some(window));
}
}

View File

@@ -8,9 +8,6 @@ use tracing_subscriber::{
fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _,
};
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "macos")]
embed_plist::embed_info_plist!("../../../resources/info.desktop_proxy.plist");
@@ -64,9 +61,6 @@ fn init_logging(log_path: &Path, console_level: LevelFilter, file_level: LevelFi
#[allow(clippy::unwrap_used)]
#[tokio::main(flavor = "current_thread")]
async fn main() {
#[cfg(target_os = "windows")]
let should_foreground = windows::allow_foreground();
let sock_path = desktop_core::ipc::path("bw");
let log_path = {
@@ -158,9 +152,6 @@ async fn main() {
// Listen to stdin and send messages to ipc processor.
msg = stdin.next() => {
#[cfg(target_os = "windows")]
should_foreground.store(true, std::sync::atomic::Ordering::Relaxed);
match msg {
Some(Ok(msg)) => {
let msg = String::from_utf8(msg.to_vec()).unwrap();

View File

@@ -1,23 +0,0 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
pub fn allow_foreground() -> Arc<AtomicBool> {
let should_foreground = Arc::new(AtomicBool::new(false));
let should_foreground_clone = should_foreground.clone();
let _ = std::thread::spawn(move || loop {
if !should_foreground_clone.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
should_foreground_clone.store(false, Ordering::Relaxed);
for _ in 0..60 {
desktop_core::biometric::windows_focus::focus_security_prompt();
std::thread::sleep(std::time::Duration::from_millis(1000));
}
});
should_foreground
}

View File

@@ -1,4 +1,4 @@
[toolchain]
channel = "1.85.0"
channel = "1.87.0"
components = [ "rustfmt", "clippy" ]
profile = "minimal"