From 8c07744133083e9cfae1cf373013684ccbaec991 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Sat, 20 Dec 2025 00:14:36 -0600 Subject: [PATCH] Add autofill IPC client methods needed for Windows IPC --- apps/desktop/desktop_native/Cargo.lock | 1 + .../autofill_provider/Cargo.toml | 9 +- .../autofill_provider/README.md | 4 + .../desktop_native/autofill_provider/build.sh | 8 +- .../autofill_provider/src/assertion.rs | 75 ++++--- .../autofill_provider/src/lib.rs | 194 +++++++++++++++--- .../autofill_provider/src/lock_status.rs | 41 ++++ .../autofill_provider/src/registration.rs | 36 ++-- .../autofill_provider/src/util.rs | 24 +++ .../src/window_handle_query.rs | 47 +++++ .../autofill_provider/uniffi-bindgen.rs | 6 + 11 files changed, 377 insertions(+), 68 deletions(-) create mode 100644 apps/desktop/desktop_native/autofill_provider/src/lock_status.rs create mode 100644 apps/desktop/desktop_native/autofill_provider/src/util.rs create mode 100644 apps/desktop/desktop_native/autofill_provider/src/window_handle_query.rs diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 36a5df20f5e..edb7f35cf6f 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -328,6 +328,7 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" name = "autofill_provider" version = "0.0.0" dependencies = [ + "base64", "desktop_core", "futures", "serde", diff --git a/apps/desktop/desktop_native/autofill_provider/Cargo.toml b/apps/desktop/desktop_native/autofill_provider/Cargo.toml index 8ce23b412c4..c23333b2d5f 100644 --- a/apps/desktop/desktop_native/autofill_provider/Cargo.toml +++ b/apps/desktop/desktop_native/autofill_provider/Cargo.toml @@ -6,7 +6,7 @@ version = { workspace = true } publish = { workspace = true } [lib] -crate-type = ["staticlib", "cdylib"] +crate-type = ["lib", "staticlib", "cdylib"] bench = false [[bin]] @@ -14,15 +14,16 @@ name = "uniffi-bindgen" path = "uniffi-bindgen.rs" [dependencies] -uniffi = { workspace = true, features = ["cli"] } - -[target.'cfg(target_os = "macos")'.dependencies] +base64 = { workspace = true} desktop_core = { path = "../core" } futures = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["sync"] } tracing = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +uniffi = { workspace = true, features = ["cli"] } tracing-subscriber = { workspace = true } tracing-oslog = "=0.3.0" diff --git a/apps/desktop/desktop_native/autofill_provider/README.md b/apps/desktop/desktop_native/autofill_provider/README.md index 1d4c1902465..86c49356161 100644 --- a/apps/desktop/desktop_native/autofill_provider/README.md +++ b/apps/desktop/desktop_native/autofill_provider/README.md @@ -1,3 +1,7 @@ +# Autofill Provider + +A library for native autofill providers to interact with a host Bitwarden desktop app. + # Explainer: Mac OS Native Passkey Provider This document describes the changes introduced in https://github.com/bitwarden/clients/pull/13963, where we introduce the MacOS Native Passkey Provider. It gives the high level explanation of the architecture and some of the quirks and additional good to know context. diff --git a/apps/desktop/desktop_native/autofill_provider/build.sh b/apps/desktop/desktop_native/autofill_provider/build.sh index ee9d9f9a11d..6807ef1fbc8 100755 --- a/apps/desktop/desktop_native/autofill_provider/build.sh +++ b/apps/desktop/desktop_native/autofill_provider/build.sh @@ -2,8 +2,12 @@ cd "$(dirname "$0")" -rm -r BitwardenMacosProviderFFI.xcframework -rm -r tmp +if [ -d "BitwardenMacosProviderFFI.xcframework" ]; then + rm -r "BitwardenMacosProviderFFI.xcframework" +fi +if [ -d "tmp" ]; then + rm -r "tmp" +fi mkdir -p ./tmp/target/universal-darwin/release/ diff --git a/apps/desktop/desktop_native/autofill_provider/src/assertion.rs b/apps/desktop/desktop_native/autofill_provider/src/assertion.rs index c5b43bb87fa..ef792c4f562 100644 --- a/apps/desktop/desktop_native/autofill_provider/src/assertion.rs +++ b/apps/desktop/desktop_native/autofill_provider/src/assertion.rs @@ -2,44 +2,60 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; +#[cfg(not(target_os = "macos"))] +use crate::TimedCallback; use crate::{BitwardenError, Callback, Position, UserVerification}; -#[derive(uniffi::Record, Debug, Serialize, Deserialize)] +#[cfg_attr(target_os = "macos", derive(uniffi::Record))] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PasskeyAssertionRequest { - rp_id: String, - client_data_hash: Vec, - user_verification: UserVerification, - allowed_credentials: Vec>, - window_xy: Position, - //extension_input: Vec, TODO: Implement support for extensions + pub rp_id: String, + pub client_data_hash: Vec, + pub user_verification: UserVerification, + pub allowed_credentials: Vec>, + pub window_xy: Position, + #[cfg(not(target_os = "macos"))] + pub client_window_handle: Vec, + #[cfg(not(target_os = "macos"))] + pub context: String, + // pub extension_input: Vec, TODO: Implement support for extensions } -#[derive(uniffi::Record, Debug, Serialize, Deserialize)] +#[cfg_attr(target_os = "macos", derive(uniffi::Record))] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PasskeyAssertionWithoutUserInterfaceRequest { - rp_id: String, - credential_id: Vec, - user_name: String, - user_handle: Vec, - record_identifier: Option, - client_data_hash: Vec, - user_verification: UserVerification, - window_xy: Position, + pub rp_id: String, + pub credential_id: Vec, + #[cfg(target_os = "macos")] + pub user_name: String, + #[cfg(target_os = "macos")] + pub user_handle: Vec, + #[cfg(target_os = "macos")] + pub record_identifier: Option, + pub client_data_hash: Vec, + pub user_verification: UserVerification, + pub window_xy: Position, + #[cfg(not(target_os = "macos"))] + pub client_window_handle: Vec, + #[cfg(not(target_os = "macos"))] + pub context: String, } -#[derive(uniffi::Record, Serialize, Deserialize)] +#[cfg_attr(target_os = "macos", derive(uniffi::Record))] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PasskeyAssertionResponse { - rp_id: String, - user_handle: Vec, - signature: Vec, - client_data_hash: Vec, - authenticator_data: Vec, - credential_id: Vec, + pub rp_id: String, + pub user_handle: Vec, + pub signature: Vec, + pub client_data_hash: Vec, + pub authenticator_data: Vec, + pub credential_id: Vec, } -#[uniffi::export(with_foreign)] +#[cfg_attr(target_os = "macos", uniffi::export(with_foreign))] pub trait PreparePasskeyAssertionCallback: Send + Sync { fn on_complete(&self, credential: PasskeyAssertionResponse); fn on_error(&self, error: BitwardenError); @@ -56,3 +72,14 @@ impl Callback for Arc { PreparePasskeyAssertionCallback::on_error(self.as_ref(), error); } } + +#[cfg(not(target_os = "macos"))] +impl PreparePasskeyAssertionCallback for TimedCallback { + fn on_complete(&self, credential: PasskeyAssertionResponse) { + self.send(Ok(credential)); + } + + fn on_error(&self, error: BitwardenError) { + self.send(Err(error)) + } +} diff --git a/apps/desktop/desktop_native/autofill_provider/src/lib.rs b/apps/desktop/desktop_native/autofill_provider/src/lib.rs index 8619a77a0f2..300f01d8982 100644 --- a/apps/desktop/desktop_native/autofill_provider/src/lib.rs +++ b/apps/desktop/desktop_native/autofill_provider/src/lib.rs @@ -1,35 +1,58 @@ -#![cfg(target_os = "macos")] #![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation +mod assertion; +mod lock_status; +mod registration; +mod util; +mod window_handle_query; use std::{ collections::HashMap, - sync::{atomic::AtomicU32, Arc, Mutex, Once}, - time::Instant, + error::Error, + fmt::Display, + sync::{ + atomic::AtomicU32, + mpsc::{self, Receiver, RecvTimeoutError, Sender}, + Arc, Mutex, + }, + time::{Duration, Instant}, }; +#[cfg(target_os = "macos")] +use std::sync::Once; + use futures::FutureExt; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tracing::{error, info}; +#[cfg(target_os = "macos")] use tracing_subscriber::{ filter::{EnvFilter, LevelFilter}, layer::SubscriberExt, util::SubscriberInitExt, }; -uniffi::setup_scaffolding!(); +use crate::{ + lock_status::{GetLockStatusCallback, LockStatusRequest}, + window_handle_query::{GetWindowHandleQueryCallback, WindowHandleQueryRequest}, +}; -mod assertion; -mod registration; - -use assertion::{ - PasskeyAssertionRequest, PasskeyAssertionWithoutUserInterfaceRequest, +pub use assertion::{ + PasskeyAssertionRequest, PasskeyAssertionResponse, PasskeyAssertionWithoutUserInterfaceRequest, PreparePasskeyAssertionCallback, }; -use registration::{PasskeyRegistrationRequest, PreparePasskeyRegistrationCallback}; +pub use lock_status::LockStatusResponse; +pub use registration::{ + PasskeyRegistrationRequest, PasskeyRegistrationResponse, PreparePasskeyRegistrationCallback, +}; +pub use window_handle_query::WindowHandleQueryResponse; +#[cfg(target_os = "macos")] +uniffi::setup_scaffolding!(); + +#[cfg(target_os = "macos")] static INIT: Once = Once::new(); -#[derive(uniffi::Enum, Debug, Serialize, Deserialize)] +#[cfg_attr(target_os = "macos", derive(uniffi::Enum))] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum UserVerification { Preferred, @@ -37,18 +60,30 @@ pub enum UserVerification { Discouraged, } -#[derive(uniffi::Record, Debug, Serialize, Deserialize)] +#[cfg_attr(target_os = "macos", derive(uniffi::Record))] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Position { pub x: i32, pub y: i32, } -#[derive(Debug, uniffi::Error, Serialize, Deserialize)] +#[cfg_attr(target_os = "macos", derive(uniffi::Error))] +#[derive(Debug, Serialize, Deserialize)] pub enum BitwardenError { Internal(String), } +impl Display for BitwardenError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Internal(msg) => write!(f, "Internal error occurred: {msg}"), + } + } +} + +impl Error for BitwardenError {} + // TODO: These have to be named differently than the actual Uniffi traits otherwise // the generated code will lead to ambiguous trait implementations // These are only used internally, so it doesn't matter that much @@ -57,16 +92,17 @@ trait Callback: Send + Sync { fn error(&self, error: BitwardenError); } -#[derive(uniffi::Enum, Debug)] -/// Store the connection status between the macOS credential provider extension +#[cfg_attr(target_os = "macos", derive(uniffi::Enum))] +#[derive(Debug)] +/// Store the connection status between the credential provider extension /// and the desktop application's IPC server. pub enum ConnectionStatus { Connected, Disconnected, } -#[derive(uniffi::Object)] -pub struct MacOSProviderClient { +#[cfg_attr(target_os = "macos", derive(uniffi::Object))] +pub struct AutofillProviderClient { to_server_send: tokio::sync::mpsc::Sender, // We need to keep track of the callbacks so we can call them when we receive a response @@ -81,7 +117,7 @@ pub struct MacOSProviderClient { #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] /// Store native desktop status information to use for IPC communication -/// between the application and the macOS credential provider. +/// between the application and the credential provider. pub struct NativeStatus { key: String, value: String, @@ -91,12 +127,31 @@ pub struct NativeStatus { // have a callback. const NO_CALLBACK_INDICATOR: u32 = 0; -#[uniffi::export] -impl MacOSProviderClient { +// These methods are not currently needed in macOS and/or cannot be exported via FFI +impl AutofillProviderClient { + pub fn is_available() -> bool { + desktop_core::ipc::path("af").exists() + } + + pub fn get_lock_status(&self, callback: Arc) { + self.send_message(LockStatusRequest {}, Some(Box::new(callback))); + } + + pub fn get_window_handle(&self, callback: Arc) { + self.send_message( + WindowHandleQueryRequest::default(), + Some(Box::new(callback)), + ); + } +} + +#[cfg_attr(target_os = "macos", uniffi::export)] +impl AutofillProviderClient { // FIXME: Remove unwraps! They panic and terminate the whole application. #[allow(clippy::unwrap_used)] - #[uniffi::constructor] + #[cfg_attr(target_os = "macos", uniffi::constructor)] pub fn connect() -> Self { + #[cfg(target_os = "macos")] INIT.call_once(|| { let filter = EnvFilter::builder() // Everything logs at `INFO` @@ -112,10 +167,12 @@ impl MacOSProviderClient { .init(); }); + tracing::debug!("Autofill provider attempting to connect to Electron IPC..."); + let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32); let (to_server_send, to_server_recv) = tokio::sync::mpsc::channel(32); - let client = MacOSProviderClient { + let client = AutofillProviderClient { to_server_send, response_callbacks_counter: AtomicU32::new(1), /* Start at 1 since 0 is reserved for * "no callback" scenarios */ @@ -244,7 +301,7 @@ enum SerializedMessage { }, } -impl MacOSProviderClient { +impl AutofillProviderClient { #[allow(clippy::unwrap_used)] fn add_callback(&self, callback: Box) -> u32 { let sequence_number = self @@ -294,3 +351,94 @@ impl MacOSProviderClient { } } } + +#[derive(Debug)] +pub enum CallbackError { + Timeout, + Cancelled, +} + +impl Display for CallbackError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Timeout => f.write_str("Callback timed out"), + Self::Cancelled => f.write_str("Callback cancelled"), + } + } +} +impl std::error::Error for CallbackError {} + +pub struct TimedCallback { + tx: Arc>>>>, + rx: Arc>>>, +} + +impl TimedCallback { + pub fn new() -> Self { + let (tx, rx) = mpsc::channel(); + Self { + tx: Arc::new(Mutex::new(Some(tx))), + rx: Arc::new(Mutex::new(rx)), + } + } + + pub fn wait_for_response( + &self, + timeout: Duration, + cancellation_token: Option>, + ) -> Result, CallbackError> { + let (tx, rx) = mpsc::channel(); + if let Some(cancellation_token) = cancellation_token { + let tx2 = tx.clone(); + let cancellation_token = Mutex::new(cancellation_token); + std::thread::spawn(move || { + if let Ok(()) = cancellation_token.lock().unwrap().recv_timeout(timeout) { + tracing::debug!("Forwarding cancellation"); + _ = tx2.send(Err(CallbackError::Cancelled)); + } + }); + } + let response_rx = self.rx.clone(); + std::thread::spawn(move || { + if let Ok(response) = response_rx.lock().unwrap().recv_timeout(timeout) { + _ = tx.send(Ok(response)); + } + }); + match rx.recv_timeout(timeout) { + Ok(Ok(response)) => Ok(response), + Ok(err @ Err(CallbackError::Cancelled)) => { + tracing::debug!("Received cancellation, dropping."); + err + } + Ok(err @ Err(CallbackError::Timeout)) => { + tracing::debug!("Request timed out, dropping."); + err + } + Err(RecvTimeoutError::Timeout) => Err(CallbackError::Timeout), + Err(_) => Err(CallbackError::Cancelled), + } + } + + fn send(&self, response: Result) { + match self.tx.lock().unwrap().take() { + Some(tx) => { + if let Err(_) = tx.send(response) { + tracing::error!("Windows provider channel closed before receiving IPC response from Electron") + } + } + None => { + tracing::error!("Callback channel used before response: multi-threading issue?"); + } + } + } +} + +impl PreparePasskeyRegistrationCallback for TimedCallback { + fn on_complete(&self, credential: PasskeyRegistrationResponse) { + self.send(Ok(credential)); + } + + fn on_error(&self, error: BitwardenError) { + self.send(Err(error)) + } +} diff --git a/apps/desktop/desktop_native/autofill_provider/src/lock_status.rs b/apps/desktop/desktop_native/autofill_provider/src/lock_status.rs new file mode 100644 index 00000000000..6029048178d --- /dev/null +++ b/apps/desktop/desktop_native/autofill_provider/src/lock_status.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::{BitwardenError, Callback, TimedCallback}; + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct LockStatusRequest {} + +#[derive(Debug, Deserialize)] +pub struct LockStatusResponse { + #[serde(rename = "isUnlocked")] + pub is_unlocked: bool, +} + +impl Callback for Arc { + fn complete(&self, response: serde_json::Value) -> Result<(), serde_json::Error> { + let response = serde_json::from_value(response)?; + self.as_ref().on_complete(response); + Ok(()) + } + + fn error(&self, error: BitwardenError) { + self.as_ref().on_error(error); + } +} + +pub trait GetLockStatusCallback: Send + Sync { + fn on_complete(&self, response: LockStatusResponse); + fn on_error(&self, error: BitwardenError); +} + +impl GetLockStatusCallback for TimedCallback { + fn on_complete(&self, response: LockStatusResponse) { + self.send(Ok(response)); + } + + fn on_error(&self, error: BitwardenError) { + self.send(Err(error)) + } +} diff --git a/apps/desktop/desktop_native/autofill_provider/src/registration.rs b/apps/desktop/desktop_native/autofill_provider/src/registration.rs index c961566a86c..6aba0ce4fbe 100644 --- a/apps/desktop/desktop_native/autofill_provider/src/registration.rs +++ b/apps/desktop/desktop_native/autofill_provider/src/registration.rs @@ -4,29 +4,35 @@ use serde::{Deserialize, Serialize}; use crate::{BitwardenError, Callback, Position, UserVerification}; -#[derive(uniffi::Record, Debug, Serialize, Deserialize)] +#[cfg_attr(target_os = "macos", derive(uniffi::Record))] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PasskeyRegistrationRequest { - rp_id: String, - user_name: String, - user_handle: Vec, - client_data_hash: Vec, - user_verification: UserVerification, - supported_algorithms: Vec, - window_xy: Position, - excluded_credentials: Vec>, + pub rp_id: String, + pub user_name: String, + pub user_handle: Vec, + pub client_data_hash: Vec, + pub user_verification: UserVerification, + pub supported_algorithms: Vec, + pub window_xy: Position, + pub excluded_credentials: Vec>, + #[cfg(not(target_os = "macos"))] + pub client_window_handle: Vec, + #[cfg(not(target_os = "macos"))] + pub context: String, } -#[derive(uniffi::Record, Serialize, Deserialize)] +#[cfg_attr(target_os = "macos", derive(uniffi::Record))] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PasskeyRegistrationResponse { - rp_id: String, - client_data_hash: Vec, - credential_id: Vec, - attestation_object: Vec, + pub rp_id: String, + pub client_data_hash: Vec, + pub credential_id: Vec, + pub attestation_object: Vec, } -#[uniffi::export(with_foreign)] +#[cfg_attr(target_os = "macos", uniffi::export(with_foreign))] pub trait PreparePasskeyRegistrationCallback: Send + Sync { fn on_complete(&self, credential: PasskeyRegistrationResponse); fn on_error(&self, error: BitwardenError); diff --git a/apps/desktop/desktop_native/autofill_provider/src/util.rs b/apps/desktop/desktop_native/autofill_provider/src/util.rs new file mode 100644 index 00000000000..8880b7b71b8 --- /dev/null +++ b/apps/desktop/desktop_native/autofill_provider/src/util.rs @@ -0,0 +1,24 @@ +use serde::{de::Visitor, Deserializer}; + +pub(crate) fn deserialize_b64<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + deserializer.deserialize_str(Base64Visitor {}) +} + +struct Base64Visitor; +impl<'de> Visitor<'de> for Base64Visitor { + type Value = Vec; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("A valid base64 string") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + STANDARD.decode(v).map_err(|err| E::custom(err)) + } +} diff --git a/apps/desktop/desktop_native/autofill_provider/src/window_handle_query.rs b/apps/desktop/desktop_native/autofill_provider/src/window_handle_query.rs new file mode 100644 index 00000000000..5f5bc526edc --- /dev/null +++ b/apps/desktop/desktop_native/autofill_provider/src/window_handle_query.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::{BitwardenError, Callback, TimedCallback}; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub(super) struct WindowHandleQueryRequest { + #[serde(rename = "windowHandle")] + window_handle: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowHandleQueryResponse { + pub is_visible: bool, + pub is_focused: bool, + #[serde(deserialize_with = "crate::util::deserialize_b64")] + pub handle: Vec, +} + +impl Callback for Arc { + fn complete(&self, response: serde_json::Value) -> Result<(), serde_json::Error> { + let response = serde_json::from_value(response)?; + self.as_ref().on_complete(response); + Ok(()) + } + + fn error(&self, error: BitwardenError) { + self.as_ref().on_error(error); + } +} + +pub trait GetWindowHandleQueryCallback: Send + Sync { + fn on_complete(&self, response: WindowHandleQueryResponse); + fn on_error(&self, error: BitwardenError); +} + +impl GetWindowHandleQueryCallback for TimedCallback { + fn on_complete(&self, response: WindowHandleQueryResponse) { + self.send(Ok(response)); + } + + fn on_error(&self, error: BitwardenError) { + self.send(Err(error)) + } +} diff --git a/apps/desktop/desktop_native/autofill_provider/uniffi-bindgen.rs b/apps/desktop/desktop_native/autofill_provider/uniffi-bindgen.rs index f6cff6cf1d9..433c6c65b37 100644 --- a/apps/desktop/desktop_native/autofill_provider/uniffi-bindgen.rs +++ b/apps/desktop/desktop_native/autofill_provider/uniffi-bindgen.rs @@ -1,3 +1,9 @@ +#[cfg(target_os = "macos")] fn main() { uniffi::uniffi_bindgen_main() } + +#[cfg(not(target_os = "macos"))] +fn main() { + unimplemented!("uniffi-bindgen is not enabled on this target."); +}