1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-19 02:44:01 +00:00

Add generic autofill provider library [PM-29786] (#18075)

* Rename macos_provider to autofill_provider

* Add autofill IPC client methods needed for Windows IPC
This commit is contained in:
Isaiah Inuwa
2026-01-27 12:41:07 -06:00
committed by jaasen-livefront
parent 22c1887990
commit f257d62c20
22 changed files with 1477 additions and 448 deletions

2
.github/CODEOWNERS vendored
View File

@@ -8,7 +8,7 @@
apps/desktop/desktop_native @bitwarden/team-platform-dev
apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/macos_provider @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/autofill_provider @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-management-dev
## No ownership for Cargo.lock and Cargo.toml to allow dependency updates

View File

@@ -187,6 +187,7 @@
"semver",
"serde",
"serde_json",
"serde_with",
"simplelog",
"style-loader",
"sysinfo",

View File

@@ -47,6 +47,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.18"
@@ -324,6 +333,23 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "autofill_provider"
version = "0.0.0"
dependencies = [
"base64",
"desktop_core",
"futures",
"serde",
"serde_json",
"serde_with",
"tokio",
"tracing",
"tracing-oslog",
"tracing-subscriber",
"uniffi",
]
[[package]]
name = "autotype"
version = "0.0.0"
@@ -603,6 +629,18 @@ dependencies = [
"windows",
]
[[package]]
name = "chrono"
version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link 0.2.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -799,6 +837,41 @@ dependencies = [
"syn",
]
[[package]]
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -810,6 +883,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc"
dependencies = [
"powerfmt",
"serde",
]
[[package]]
name = "desktop_core"
version = "0.0.0"
@@ -988,6 +1071,12 @@ version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecdsa"
version = "0.16.9"
@@ -1410,6 +1499,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.3"
@@ -1425,7 +1520,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown",
"hashbrown 0.15.3",
]
[[package]]
@@ -1476,6 +1571,30 @@ dependencies = [
"windows",
]
[[package]]
name = "iana-time-zone"
version = "0.1.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.0.0"
@@ -1562,6 +1681,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.0.3"
@@ -1583,6 +1708,17 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.9.0"
@@ -1590,7 +1726,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.15.3",
"serde",
]
[[package]]
@@ -1744,21 +1881,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "macos_provider"
version = "0.0.0"
dependencies = [
"desktop_core",
"futures",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-oslog",
"tracing-subscriber",
"uniffi",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2017,6 +2139,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -2315,7 +2443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
dependencies = [
"fixedbitset",
"indexmap",
"indexmap 2.9.0",
]
[[package]]
@@ -2441,6 +2569,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -2622,6 +2756,26 @@ dependencies = [
"thiserror 2.0.17",
]
[[package]]
name = "ref-cast"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "regex-automata"
version = "0.4.9"
@@ -2766,6 +2920,30 @@ dependencies = [
"sdd",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -2911,6 +3089,38 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e"
dependencies = [
"base64",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.9.0",
"schemars 0.9.0",
"schemars 1.2.0",
"serde",
"serde_derive",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serial_test"
version = "3.3.1"
@@ -3231,6 +3441,37 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
[[package]]
name = "time-macros"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.1"
@@ -3298,7 +3539,7 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8"
dependencies = [
"indexmap",
"indexmap 2.9.0",
"serde",
"serde_spanned",
"toml_datetime 0.7.0",
@@ -3328,7 +3569,7 @@ version = "0.22.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
dependencies = [
"indexmap",
"indexmap 2.9.0",
"toml_datetime 0.6.9",
"winnow",
]

View File

@@ -1,11 +1,11 @@
[workspace]
resolver = "2"
members = [
"autofill_provider",
"autotype",
"bitwarden_chromium_import_helper",
"chromium_importer",
"core",
"macos_provider",
"napi",
"process_isolation",
"proxy",
@@ -58,6 +58,7 @@ security-framework = "=3.5.1"
security-framework-sys = "=2.15.0"
serde = "=1.0.209"
serde_json = "=1.0.127"
serde_with = "=3.14.1"
sha2 = "=0.10.9"
ssh-encoding = "=0.2.0"
ssh-key = { version = "=0.6.7", default-features = false }

View File

@@ -1,12 +1,12 @@
[package]
name = "macos_provider"
name = "autofill_provider"
edition = { workspace = true }
license = { workspace = true }
version = { workspace = true }
publish = { workspace = true }
[lib]
crate-type = ["staticlib", "cdylib"]
crate-type = ["lib", "staticlib", "cdylib"]
bench = false
[[bin]]
@@ -14,17 +14,19 @@ 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 }
serde_with = { workspace = true, features = ["base64"] }
tokio = { workspace = true, features = ["sync"] }
tracing = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
tracing-oslog = "=0.3.0"
tracing-subscriber = { workspace = true }
uniffi = { workspace = true, features = ["cli"] }
[build-dependencies]
uniffi = { workspace = true, features = ["build"] }

View File

@@ -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.

View File

@@ -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/
@@ -11,17 +15,17 @@ mkdir -p ./tmp/target/universal-darwin/release/
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
cargo build --package macos_provider --target aarch64-apple-darwin --release
cargo build --package macos_provider --target x86_64-apple-darwin --release
cargo build --package autofill_provider --target aarch64-apple-darwin --release
cargo build --package autofill_provider --target x86_64-apple-darwin --release
# Create universal libraries
lipo -create ../target/aarch64-apple-darwin/release/libmacos_provider.a \
../target/x86_64-apple-darwin/release/libmacos_provider.a \
-output ./tmp/target/universal-darwin/release/libmacos_provider.a
lipo -create ../target/aarch64-apple-darwin/release/libautofill_provider.a \
../target/x86_64-apple-darwin/release/libautofill_provider.a \
-output ./tmp/target/universal-darwin/release/libautofill_provider.a
# Generate swift bindings
cargo run --bin uniffi-bindgen --features uniffi/cli generate \
../target/aarch64-apple-darwin/release/libmacos_provider.dylib \
../target/aarch64-apple-darwin/release/libautofill_provider.dylib \
--library \
--language swift \
--no-format \
@@ -38,7 +42,7 @@ cat ./tmp/bindings/*.modulemap > ./tmp/Headers/module.modulemap
# Build xcframework
xcodebuild -create-xcframework \
-library ./tmp/target/universal-darwin/release/libmacos_provider.a \
-library ./tmp/target/universal-darwin/release/libautofill_provider.a \
-headers ./tmp/Headers \
-output ./BitwardenMacosProviderFFI.xcframework

View File

@@ -0,0 +1,184 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
#[cfg(not(target_os = "macos"))]
use crate::TimedCallback;
use crate::{BitwardenError, Callback, Position, UserVerification};
/// Request to assert a credential.
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionRequest {
/// Relying Party ID for the request.
pub rp_id: String,
/// SHA-256 hash of the `clientDataJSON` for the assertion request.
pub client_data_hash: Vec<u8>,
/// User verification preference.
pub user_verification: UserVerification,
/// List of allowed credential IDs.
pub allowed_credentials: Vec<Vec<u8>>,
/// Coordinates of the center of the WebAuthn client's window, relative to
/// the top-left point on the screen.
/// # Operating System Differences
///
/// ## macOS
/// Note that macOS APIs gives points relative to the bottom-left point on the
/// screen by default, so the y-coordinate will be flipped.
///
/// ## Windows
/// On Windows, this must be logical pixels, not physical pixels.
pub window_xy: Position,
/// Byte string representing the native OS window handle for the WebAuthn client.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is a HWND.
#[cfg(not(target_os = "macos"))]
pub client_window_handle: Vec<u8>,
/// Native context required for callbacks to the OS. Format differs on the OS.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is a base64-string representing the following data:
/// `request transaction id (GUID, 16 bytes) || SHA-256(pluginOperationRequest)`
#[cfg(not(target_os = "macos"))]
pub context: String,
// TODO(PM-30510): Implement support for extensions
// pub extension_input: Vec<u8>,
}
/// Request to assert a credential without user interaction.
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
/// Relying Party ID.
pub rp_id: String,
/// The allowed credential ID for the request.
pub credential_id: Vec<u8>,
/// The user name for the credential that was previously given to the OS.
#[cfg(target_os = "macos")]
pub user_name: String,
/// The user ID for the credential that was previously given to the OS.
#[cfg(target_os = "macos")]
pub user_handle: Vec<u8>,
/// The app-specific local identifier for the credential, in our case, the
/// cipher ID.
#[cfg(target_os = "macos")]
pub record_identifier: Option<String>,
/// SHA-256 hash of the `clientDataJSON` for the assertion request.
pub client_data_hash: Vec<u8>,
/// User verification preference.
pub user_verification: UserVerification,
/// Coordinates of the center of the WebAuthn client's window, relative to
/// the top-left point on the screen.
/// # Operating System Differences
///
/// ## macOS
/// Note that macOS APIs gives points relative to the bottom-left point on the
/// screen by default, so the y-coordinate will be flipped.
///
/// ## Windows
/// On Windows, this must be logical pixels, not physical pixels.
pub window_xy: Position,
/// Byte string representing the native OS window handle for the WebAuthn client.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is a HWND.
#[cfg(not(target_os = "macos"))]
pub client_window_handle: Vec<u8>,
/// Native context required for callbacks to the OS. Format differs on the OS.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is `request transaction id () || SHA-256(pluginOperationRequest)`.
#[cfg(not(target_os = "macos"))]
pub context: String,
}
/// Response for a passkey assertion request.
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionResponse {
/// Relying Party ID.
pub rp_id: String,
/// The user ID for the credential that was previously given to the OS.
pub user_handle: Vec<u8>,
/// The signature for the WebAuthn attestation response.
pub signature: Vec<u8>,
/// SHA-256 hash of the `clientDataJSON` used in the assertion.
pub client_data_hash: Vec<u8>,
/// The WebAuthn authenticator data structure.
pub authenticator_data: Vec<u8>,
/// The ID for the attested credential.
pub credential_id: Vec<u8>,
}
/// Callback to process a response to passkey assertion request.
#[cfg_attr(target_os = "macos", uniffi::export(with_foreign))]
pub trait PreparePasskeyAssertionCallback: Send + Sync {
/// Function to call if a successful response is returned.
fn on_complete(&self, credential: PasskeyAssertionResponse);
/// Function to call if an error response is returned.
fn on_error(&self, error: BitwardenError);
}
impl Callback for Arc<dyn PreparePasskeyAssertionCallback> {
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
let credential = serde_json::from_value(credential)?;
PreparePasskeyAssertionCallback::on_complete(self.as_ref(), credential);
Ok(())
}
fn error(&self, error: BitwardenError) {
PreparePasskeyAssertionCallback::on_error(self.as_ref(), error);
}
}
#[cfg(not(target_os = "macos"))]
impl PreparePasskeyAssertionCallback for TimedCallback<PasskeyAssertionResponse> {
fn on_complete(&self, credential: PasskeyAssertionResponse) {
self.send(Ok(credential));
}
fn on_error(&self, error: BitwardenError) {
self.send(Err(error));
}
}

View File

@@ -0,0 +1,754 @@
#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation
mod assertion;
mod lock_status;
mod registration;
mod window_handle_query;
#[cfg(target_os = "macos")]
use std::sync::Once;
use std::{
collections::HashMap,
error::Error,
fmt::Display,
path::PathBuf,
sync::{
atomic::AtomicU32,
mpsc::{self, Receiver, RecvTimeoutError, Sender},
Arc, Mutex,
},
time::{Duration, Instant},
};
pub use assertion::{
PasskeyAssertionRequest, PasskeyAssertionResponse, PasskeyAssertionWithoutUserInterfaceRequest,
PreparePasskeyAssertionCallback,
};
use futures::FutureExt;
pub use lock_status::LockStatusResponse;
pub use registration::{
PasskeyRegistrationRequest, PasskeyRegistrationResponse, PreparePasskeyRegistrationCallback,
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tracing::{error, info};
#[cfg(target_os = "macos")]
use tracing_subscriber::{
filter::{EnvFilter, LevelFilter},
layer::SubscriberExt,
util::SubscriberInitExt,
};
pub use window_handle_query::WindowHandleQueryResponse;
use crate::{
lock_status::{GetLockStatusCallback, LockStatusRequest},
window_handle_query::{GetWindowHandleQueryCallback, WindowHandleQueryRequest},
};
#[cfg(target_os = "macos")]
uniffi::setup_scaffolding!();
#[cfg(target_os = "macos")]
static INIT: Once = Once::new();
/// User verification preference for WebAuthn requests.
#[cfg_attr(target_os = "macos", derive(uniffi::Enum))]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum UserVerification {
Preferred,
Required,
Discouraged,
}
/// Coordinates representing a point on the screen.
#[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,
}
#[cfg_attr(target_os = "macos", derive(uniffi::Error))]
#[derive(Debug, Serialize, Deserialize)]
pub enum BitwardenError {
Internal(String),
Disconnected,
}
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}"),
Self::Disconnected => {
write!(f, "Client is disconnected from autofill IPC service")
}
}
}
}
impl Error for BitwardenError {}
// These methods are named differently than the actual Uniffi traits (without
// the `on_` prefix) to avoid ambiguous trait implementations in the generated
// code.
trait Callback: Send + Sync {
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error>;
fn error(&self, error: BitwardenError);
}
/// Store the connection status between the credential provider extension
/// and the desktop application's IPC server.
#[cfg_attr(target_os = "macos", derive(uniffi::Enum))]
#[derive(Debug)]
pub enum ConnectionStatus {
Connected,
Disconnected,
}
/// A client to send and receive messages to the autofill service on the desktop
/// client.
///
/// # Usage
///
/// In order to accommodate desktop app startup delays and non-blocking
/// requirements for native providers, this initialization of the client is
/// non-blocking. When calling [`AutofillProviderClient::connect()`], the
/// connection is not established immediately, but may be established later in
/// the background or may fail to be established.
///
/// Before calling [`AutofillProviderClient::connect()`], first check whether
/// the desktop app is running with [`AutofillProviderClient::is_available`],
/// and attempt to start it if it is not running. Then, attempt to connect, retrying as necessary.
/// Before calling any other methods, check the connection status using
/// [`AutofillProviderClient::get_connection_status()`].
///
/// # Examples
///
/// ```no_run
/// use std::{sync::Arc, time::Duration};
///
/// use autofill_provider::{AutofillProviderClient, ConnectionStatus, TimedCallback};
///
/// fn establish_connection() -> Option<AutofillProviderClient> {
/// if !AutofillProviderClient::is_available() {
/// // Start application
/// }
/// let max_attempts = 20;
/// let delay = Duration::from_millis(300);
///
/// for attempt in 0..=max_attempts {
/// let client = AutofillProviderClient::connect();
/// if attempt != 0 {
/// // Use whatever sleep method is appropriate
/// std::thread::sleep(delay + Duration::from_millis(100 * attempt));
/// }
/// if let ConnectionStatus::Connected = client.get_connection_status() {
/// return Some(client);
/// }
/// };
/// None
/// }
///
/// if let Some(client) = establish_connection() {
/// // use client here
/// }
/// ```
#[cfg_attr(target_os = "macos", derive(uniffi::Object))]
pub struct AutofillProviderClient {
to_server_send: tokio::sync::mpsc::Sender<String>,
// We need to keep track of the callbacks so we can call them when we receive a response
response_callbacks_counter: AtomicU32,
#[allow(clippy::type_complexity)]
response_callbacks_queue: Arc<Mutex<HashMap<u32, (Box<dyn Callback>, Instant)>>>,
// Flag to track connection status - atomic for thread safety without locks
connection_status: Arc<std::sync::atomic::AtomicBool>,
}
/// Store native desktop status information to use for IPC communication
/// between the application and the credential provider.
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeStatus {
key: String,
value: String,
}
// In our callback management, 0 is a reserved sequence number indicating that a message does not
// have a callback.
const NO_CALLBACK_INDICATOR: u32 = 0;
#[cfg(not(test))]
static IPC_PATH: &str = "af";
#[cfg(test)]
static IPC_PATH: &str = "af-test";
// These methods are not currently needed in macOS and/or cannot be exported via FFI
impl AutofillProviderClient {
/// Whether the client is immediately available for connection.
pub fn is_available() -> bool {
desktop_core::ipc::path(IPC_PATH).exists()
}
/// Request the desktop client's lock status.
pub fn get_lock_status(&self, callback: Arc<dyn GetLockStatusCallback>) {
self.send_message(LockStatusRequest {}, Some(Box::new(callback)));
}
/// Requests details about the desktop client's native window.
pub fn get_window_handle(&self, callback: Arc<dyn GetWindowHandleQueryCallback>) {
self.send_message(
WindowHandleQueryRequest::default(),
Some(Box::new(callback)),
);
}
fn connect_to_path(path: PathBuf) -> Self {
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 = AutofillProviderClient {
to_server_send,
response_callbacks_counter: AtomicU32::new(1), /* Start at 1 since 0 is reserved for
* "no callback" scenarios */
response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())),
connection_status: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
let queue = client.response_callbacks_queue.clone();
let connection_status = client.connection_status.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Can't create runtime");
rt.spawn(
desktop_core::ipc::client::connect(path.clone(), from_server_send, to_server_recv)
.map(move |r| {
if let Err(err) = r {
tracing::error!(
?path,
"Failed to connect to autofill IPC server: {err}"
);
}
}),
);
rt.block_on(async move {
while let Some(message) = from_server_recv.recv().await {
match serde_json::from_str::<SerializedMessage>(&message) {
Ok(SerializedMessage::Command(CommandMessage::Connected)) => {
info!("Connected to server");
connection_status.store(true, std::sync::atomic::Ordering::Relaxed);
}
Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => {
info!("Disconnected from server");
connection_status.store(false, std::sync::atomic::Ordering::Relaxed);
}
Ok(SerializedMessage::Message {
sequence_number,
value,
}) => match queue.lock().expect("not poisoned").remove(&sequence_number) {
Some((cb, request_start_time)) => {
info!(
"Time to process request: {:?}",
request_start_time.elapsed()
);
match value {
Ok(value) => {
if let Err(e) = cb.complete(value) {
error!(error = %e, "Error deserializing message");
}
}
Err(e) => {
error!(error = ?e, "Error processing message");
cb.error(e);
}
}
}
None => {
error!(sequence_number, "No callback found for sequence number");
}
},
Err(e) => {
error!(error = %e, %message, "Error deserializing message");
}
};
}
});
});
client
}
}
#[cfg_attr(target_os = "macos", uniffi::export)]
impl AutofillProviderClient {
/// Asynchronously initiates a connection to the autofill service on the desktop client.
///
/// See documentation at the top-level of [this struct][AutofillProviderClient] for usage
/// information.
#[cfg_attr(target_os = "macos", uniffi::constructor)]
pub fn connect() -> Self {
tracing::trace!("Autofill provider attempting to connect to Electron IPC...");
let path = desktop_core::ipc::path(IPC_PATH);
Self::connect_to_path(path)
}
/// Send a one-way key-value message to the desktop client.
pub fn send_native_status(&self, key: String, value: String) {
let status = NativeStatus { key, value };
self.send_message(status, None);
}
/// Send a request to create a new passkey to the desktop client.
pub fn prepare_passkey_registration(
&self,
request: PasskeyRegistrationRequest,
callback: Arc<dyn PreparePasskeyRegistrationCallback>,
) {
self.send_message(request, Some(Box::new(callback)));
}
/// Send a request to assert a passkey to the desktop client.
pub fn prepare_passkey_assertion(
&self,
request: PasskeyAssertionRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>,
) {
self.send_message(request, Some(Box::new(callback)));
}
/// Send a request to assert a passkey, without prompting the user, to the desktop client.
pub fn prepare_passkey_assertion_without_user_interface(
&self,
request: PasskeyAssertionWithoutUserInterfaceRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>,
) {
self.send_message(request, Some(Box::new(callback)));
}
/// Return the status this client's connection to the desktop client.
pub fn get_connection_status(&self) -> ConnectionStatus {
let is_connected = self
.connection_status
.load(std::sync::atomic::Ordering::Relaxed);
if is_connected {
ConnectionStatus::Connected
} else {
ConnectionStatus::Disconnected
}
}
}
#[cfg(target_os = "macos")]
#[uniffi::export]
pub fn initialize_logging() {
INIT.call_once(|| {
let filter = EnvFilter::builder()
// Everything logs at `INFO`
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::registry()
.with(filter)
.with(tracing_oslog::OsLogger::new(
"com.bitwarden.desktop.autofill-extension",
"default",
))
.init();
});
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "camelCase")]
enum CommandMessage {
Connected,
Disconnected,
}
#[derive(Serialize, Deserialize)]
#[serde(untagged, rename_all = "camelCase")]
enum SerializedMessage {
Command(CommandMessage),
Message {
sequence_number: u32,
value: Result<serde_json::Value, BitwardenError>,
},
}
impl AutofillProviderClient {
fn add_callback(&self, callback: Box<dyn Callback>) -> u32 {
let sequence_number = self
.response_callbacks_counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.response_callbacks_queue
.lock()
.expect("response callbacks queue mutex should not be poisoned")
.insert(sequence_number, (callback, Instant::now()));
sequence_number
}
fn send_message(
&self,
message: impl Serialize + DeserializeOwned,
callback: Option<Box<dyn Callback>>,
) {
if let ConnectionStatus::Disconnected = self.get_connection_status() {
if let Some(callback) = callback {
callback.error(BitwardenError::Disconnected);
}
return;
}
let sequence_number = if let Some(callback) = callback {
self.add_callback(callback)
} else {
NO_CALLBACK_INDICATOR
};
if let Err(e) = send_message_helper(sequence_number, message, &self.to_server_send) {
// Make sure we remove the callback from the queue if we can't send the message
if sequence_number != NO_CALLBACK_INDICATOR {
if let Some((callback, _)) = self
.response_callbacks_queue
.lock()
.expect("response callbacks queue mutex should not be poisoned")
.remove(&sequence_number)
{
callback.error(BitwardenError::Internal(format!(
"Error sending message: {e}"
)));
}
}
}
}
}
// Wrapped in Result<> to allow using ? for clarity.
fn send_message_helper(
sequence_number: u32,
message: impl Serialize + DeserializeOwned,
tx: &tokio::sync::mpsc::Sender<String>,
) -> Result<(), BitwardenError> {
let value = serde_json::to_value(message).map_err(|err| {
BitwardenError::Internal(format!("Could not represent message as JSON: {err}"))
})?;
let message = SerializedMessage::Message {
sequence_number,
value: Ok(value),
};
let json = serde_json::to_string(&message).map_err(|err| {
BitwardenError::Internal(format!("Could not serialize message as JSON: {err}"))
})?;
// The OS calls us serially, and we only need 1-3 concurrent requests
// (passkey request, cancellation, maybe user verification).
// So it's safe to send on this thread since there should always be enough
// room in the receiver buffer to send.
tx.blocking_send(json)
.map_err(|_| BitwardenError::Disconnected)?;
Ok(())
}
/// Types of errors for callbacks.
#[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 {}
type CallbackResponse<T> = Result<T, BitwardenError>;
/// An implementation of a callback handler that can take a deadline.
pub struct TimedCallback<T> {
tx: Arc<Mutex<Option<Sender<CallbackResponse<T>>>>>,
rx: Arc<Mutex<Receiver<CallbackResponse<T>>>>,
}
impl<T: Send + 'static> Default for TimedCallback<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: Send + 'static> TimedCallback<T> {
/// Instantiates a new callback handler.
pub fn new() -> Self {
let (tx, rx) = mpsc::channel();
Self {
tx: Arc::new(Mutex::new(Some(tx))),
rx: Arc::new(Mutex::new(rx)),
}
}
/// Block the current thread until either a response is received, or the
/// specified timeout has passed.
///
/// # Examples
///
/// ```no_run
/// use std::{sync::Arc, time::Duration};
///
/// use autofill_provider::{AutofillProviderClient, TimedCallback};
///
/// let client = AutofillProviderClient::connect();
/// let callback = Arc::new(TimedCallback::new());
/// client.get_lock_status(callback.clone());
/// match callback.wait_for_response(Duration::from_secs(3), None) {
/// Ok(Ok(response)) => Ok(response),
/// Ok(Err(err)) => Err(format!("GetLockStatus() call failed: {err}")),
/// Err(_) => Err(format!("GetLockStatus() call timed out")),
/// }.unwrap();
/// ```
pub fn wait_for_response(
&self,
timeout: Duration,
cancellation_token: Option<Receiver<()>>,
) -> Result<Result<T, BitwardenError>, 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()
.expect("not poisoned")
.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()
.expect("not poisoned")
.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::warn!("Request timed out, dropping.");
err
}
Err(RecvTimeoutError::Timeout) => Err(CallbackError::Timeout),
Err(_) => Err(CallbackError::Cancelled),
}
}
fn send(&self, response: Result<T, BitwardenError>) {
match self.tx.lock().expect("not poisoned").take() {
Some(tx) => {
if tx.send(response).is_err() {
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<PasskeyRegistrationResponse> {
fn on_complete(&self, credential: PasskeyRegistrationResponse) {
self.send(Ok(credential));
}
fn on_error(&self, error: BitwardenError) {
self.send(Err(error));
}
}
#[cfg(test)]
mod tests {
//! For debugging test failures, it may be useful to enable tracing to see
//! the request flow more easily. You can do that by adding the following
//! line to the beginning of the `#[test]` function you're working on:
//!
//! ```no_run
//! tracing_subscriber::fmt::init();
//! ```
//!
//! After that, you can set `RUST_LOG=debug` and run `cargo test` to see the traces.
use std::{
path::PathBuf,
sync::{atomic::AtomicU32, Arc},
time::Duration,
};
use desktop_core::ipc::server::MessageType;
use serde_json::{json, Value};
use tokio::sync::mpsc;
use tracing::Level;
use crate::{
AutofillProviderClient, BitwardenError, ConnectionStatus, LockStatusRequest,
SerializedMessage, TimedCallback, IPC_PATH,
};
/// Generates a path for a server and client to connect with.
///
/// [`AutofillProviderClient`] is currently hardcoded to use sockets from the filesystem.
/// In order for paths not to conflict between tests, we use a counter and add it to the path
/// name.
fn get_server_path() -> PathBuf {
static SERVER_COUNTER: AtomicU32 = AtomicU32::new(0);
let counter = SERVER_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let name = format!("{}-{}", IPC_PATH, counter);
desktop_core::ipc::path(&name)
}
/// Sets up an in-memory server based on the passed handler and returns a client to the server.
fn get_client<
F: Fn(Result<Value, BitwardenError>) -> Result<Value, BitwardenError> + Send + 'static,
>(
handler: F,
) -> AutofillProviderClient {
let (signal_tx, signal_rx) = std::sync::mpsc::channel();
let path = get_server_path();
let server_path = path.clone();
// Start server thread
std::thread::spawn(move || {
let _span = tracing::span!(Level::DEBUG, "server").entered();
tracing::info!("Starting server thread");
let (tx, mut rx) = mpsc::channel(8);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
.unwrap();
rt.block_on(async move {
tracing::debug!(?server_path, "Starting server");
let server = desktop_core::ipc::server::Server::start(&server_path, tx).unwrap();
// Signal to main thread that the server is ready to process messages.
tracing::debug!("Server started");
signal_tx.send(()).unwrap();
// Handle incoming messages
tracing::debug!("Waiting for messages");
while let Some(data) = rx.recv().await {
tracing::debug!("Received {data:?}");
match data.kind {
MessageType::Connected => {}
MessageType::Disconnected => {}
MessageType::Message => {
// Deserialize and handle messages using the given handler function.
let msg: SerializedMessage =
serde_json::from_str(&data.message.unwrap()).unwrap();
if let SerializedMessage::Message {
sequence_number,
value,
} = msg
{
let response = serde_json::to_string(&SerializedMessage::Message {
sequence_number,
value: handler(value),
})
.unwrap();
server.send(response).unwrap();
}
}
}
}
});
});
// Wait for server to startup and client to connect to server before returning client to
// test method.
let _span = tracing::span!(Level::DEBUG, "client");
tracing::debug!("Waiting for server...");
signal_rx.recv_timeout(Duration::from_millis(1000)).unwrap();
// This starts a background task to connect to the server.
tracing::debug!("Starting client...");
let client = AutofillProviderClient::connect_to_path(path.to_path_buf());
// The client connects to the server asynchronously in a background
// thread, so wait for client to report itself as Connected so that test
// methods don't have to do this everytime.
// Note, this has the potential to be flaky on a very busy server, but that's unavoidable
// with the current API.
tracing::debug!("Client connecting...");
for _ in 0..20 {
if let ConnectionStatus::Connected = client.get_connection_status() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(matches!(
client.get_connection_status(),
ConnectionStatus::Connected
));
client
}
#[test]
fn test_client_throws_error_on_method_call_when_disconnected() {
// There is no server running at this path, so this client should always be disconnected.
let client = AutofillProviderClient::connect_to_path(get_server_path());
// use an arbitrary request to test whether the client is disconnected.
let callback = Arc::new(TimedCallback::new());
client.get_lock_status(callback.clone());
let response = callback
.wait_for_response(Duration::from_millis(10), None)
.unwrap();
assert!(matches!(response, Err(BitwardenError::Disconnected)));
}
#[test]
fn test_client_parses_get_lock_status_response_when_valid_json_is_returned() {
// The server should expect a lock status request and return a valid response.
let handler = |value: Result<Value, BitwardenError>| {
let value = value.unwrap();
if let Ok(LockStatusRequest {}) = serde_json::from_value(value.clone()) {
Ok(json!({"isUnlocked": true}))
} else {
Err(BitwardenError::Internal(format!(
"Expected LockStatusRequest, received: {value:?}"
)))
}
};
// send a lock status request
let client = get_client(handler);
let callback = Arc::new(TimedCallback::new());
client.get_lock_status(callback.clone());
let response = callback
.wait_for_response(Duration::from_millis(3000), None)
.unwrap()
.unwrap();
assert!(response.is_unlocked);
}
}

View File

@@ -0,0 +1,48 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{BitwardenError, Callback, TimedCallback};
/// Request to retrieve the lock status of the desktop client.
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct LockStatusRequest {}
/// Response for the lock status of the desktop client.
#[derive(Debug, Deserialize)]
pub struct LockStatusResponse {
/// Whether the desktop client is unlocked.
#[serde(rename = "isUnlocked")]
pub is_unlocked: bool,
}
impl Callback for Arc<dyn GetLockStatusCallback> {
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);
}
}
/// Callback to process a response to a lock status request.
pub trait GetLockStatusCallback: Send + Sync {
/// Function to call if a successful response is returned.
fn on_complete(&self, response: LockStatusResponse);
/// Function to call if an error response is returned.
fn on_error(&self, error: BitwardenError);
}
impl GetLockStatusCallback for TimedCallback<LockStatusResponse> {
fn on_complete(&self, response: LockStatusResponse) {
self.send(Ok(response));
}
fn on_error(&self, error: BitwardenError) {
self.send(Err(error));
}
}

View File

@@ -0,0 +1,107 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{BitwardenError, Callback, Position, UserVerification};
/// Request to create a credential.
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationRequest {
/// Relying Party ID for the request.
pub rp_id: String,
/// The user name for the credential that was previously given to the OS.
pub user_name: String,
/// The user ID for the credential that was previously given to the OS.
pub user_handle: Vec<u8>,
/// SHA-256 hash of the `clientDataJSON` for the registration request.
pub client_data_hash: Vec<u8>,
/// User verification preference.
pub user_verification: UserVerification,
/// Supported key algorithms in COSE format.
pub supported_algorithms: Vec<i32>,
/// Coordinates of the center of the WebAuthn client's window, relative to
/// the top-left point on the screen.
/// # Operating System Differences
///
/// ## macOS
/// Note that macOS APIs gives points relative to the bottom-left point on the
/// screen by default, so the y-coordinate will be flipped.
///
/// ## Windows
/// On Windows, this must be logical pixels, not physical pixels.
pub window_xy: Position,
/// List of excluded credential IDs.
pub excluded_credentials: Vec<Vec<u8>>,
/// Byte string representing the native OS window handle for the WebAuthn client.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is a HWND.
#[cfg(not(target_os = "macos"))]
pub client_window_handle: Vec<u8>,
/// Native context required for callbacks to the OS. Format differs by OS.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is a base64-string representing the following data:
/// `request transaction id (GUID, 16 bytes) || SHA-256(pluginOperationRequest)`
#[cfg(not(target_os = "macos"))]
pub context: String,
}
/// Response for a passkey registration request.
#[cfg_attr(target_os = "macos", derive(uniffi::Record))]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationResponse {
/// Relying Party ID.
pub rp_id: String,
/// SHA-256 hash of the `clientDataJSON` used in the registration.
pub client_data_hash: Vec<u8>,
/// The ID for the created credential.
pub credential_id: Vec<u8>,
/// WebAuthn attestation object.
pub attestation_object: Vec<u8>,
}
/// Callback to process a response to passkey registration request.
#[cfg_attr(target_os = "macos", uniffi::export(with_foreign))]
pub trait PreparePasskeyRegistrationCallback: Send + Sync {
/// Function to call if a successful response is returned.
fn on_complete(&self, credential: PasskeyRegistrationResponse);
/// Function to call if an error response is returned.
fn on_error(&self, error: BitwardenError);
}
impl Callback for Arc<dyn PreparePasskeyRegistrationCallback> {
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
let credential = serde_json::from_value(credential)?;
PreparePasskeyRegistrationCallback::on_complete(self.as_ref(), credential);
Ok(())
}
fn error(&self, error: BitwardenError) {
PreparePasskeyRegistrationCallback::on_error(self.as_ref(), error);
}
}

View File

@@ -0,0 +1,75 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_with::{
base64::{Base64, Standard},
formats::Padded,
serde_as,
};
use crate::{BitwardenError, Callback, TimedCallback};
/// Request to get the window handle of the desktop client.
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct WindowHandleQueryRequest {
/// Marker field for parsing; data is never read.
///
/// TODO: this is used to disambiguate parsing the type in desktop_napi.
/// This will be cleaned up in PM-23485.
window_handle: String,
}
/// Response to window handle request.
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WindowHandleQueryResponse {
/// Whether the desktop client is currently visible.
pub is_visible: bool,
/// Whether the desktop client is currently focused.
pub is_focused: bool,
/// Byte string representing the native OS window handle for the desktop client.
/// # Operating System Differences
///
/// ## macOS
/// Unused.
///
/// ## Windows
/// On Windows, this is a HWND.
#[serde_as(as = "Base64<Standard, Padded>")]
pub handle: Vec<u8>,
}
impl Callback for Arc<dyn GetWindowHandleQueryCallback> {
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);
}
}
/// Callback to process a response to a window handle query request.
pub trait GetWindowHandleQueryCallback: Send + Sync {
/// Function to call if a successful response is returned.
fn on_complete(&self, response: WindowHandleQueryResponse);
/// Function to call if an error response is returned.
fn on_error(&self, error: BitwardenError);
}
impl GetWindowHandleQueryCallback for TimedCallback<WindowHandleQueryResponse> {
fn on_complete(&self, response: WindowHandleQueryResponse) {
self.send(Ok(response));
}
fn on_error(&self, error: BitwardenError) {
self.send(Err(error));
}
}

View File

@@ -0,0 +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.");
}

View File

@@ -1,58 +0,0 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{BitwardenError, Callback, Position, UserVerification};
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionRequest {
rp_id: String,
client_data_hash: Vec<u8>,
user_verification: UserVerification,
allowed_credentials: Vec<Vec<u8>>,
window_xy: Position,
//extension_input: Vec<u8>, TODO: Implement support for extensions
}
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
rp_id: String,
credential_id: Vec<u8>,
user_name: String,
user_handle: Vec<u8>,
record_identifier: Option<String>,
client_data_hash: Vec<u8>,
user_verification: UserVerification,
window_xy: Position,
}
#[derive(uniffi::Record, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionResponse {
rp_id: String,
user_handle: Vec<u8>,
signature: Vec<u8>,
client_data_hash: Vec<u8>,
authenticator_data: Vec<u8>,
credential_id: Vec<u8>,
}
#[uniffi::export(with_foreign)]
pub trait PreparePasskeyAssertionCallback: Send + Sync {
fn on_complete(&self, credential: PasskeyAssertionResponse);
fn on_error(&self, error: BitwardenError);
}
impl Callback for Arc<dyn PreparePasskeyAssertionCallback> {
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
let credential = serde_json::from_value(credential)?;
PreparePasskeyAssertionCallback::on_complete(self.as_ref(), credential);
Ok(())
}
fn error(&self, error: BitwardenError) {
PreparePasskeyAssertionCallback::on_error(self.as_ref(), error);
}
}

View File

@@ -1,296 +0,0 @@
#![cfg(target_os = "macos")]
#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation
use std::{
collections::HashMap,
sync::{atomic::AtomicU32, Arc, Mutex, Once},
time::Instant,
};
use futures::FutureExt;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tracing::{error, info};
use tracing_subscriber::{
filter::{EnvFilter, LevelFilter},
layer::SubscriberExt,
util::SubscriberInitExt,
};
uniffi::setup_scaffolding!();
mod assertion;
mod registration;
use assertion::{
PasskeyAssertionRequest, PasskeyAssertionWithoutUserInterfaceRequest,
PreparePasskeyAssertionCallback,
};
use registration::{PasskeyRegistrationRequest, PreparePasskeyRegistrationCallback};
static INIT: Once = Once::new();
#[derive(uniffi::Enum, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum UserVerification {
Preferred,
Required,
Discouraged,
}
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Position {
pub x: i32,
pub y: i32,
}
#[derive(Debug, uniffi::Error, Serialize, Deserialize)]
pub enum BitwardenError {
Internal(String),
}
// 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
trait Callback: Send + Sync {
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error>;
fn error(&self, error: BitwardenError);
}
#[derive(uniffi::Enum, Debug)]
/// Store the connection status between the macOS credential provider extension
/// and the desktop application's IPC server.
pub enum ConnectionStatus {
Connected,
Disconnected,
}
#[derive(uniffi::Object)]
pub struct MacOSProviderClient {
to_server_send: tokio::sync::mpsc::Sender<String>,
// We need to keep track of the callbacks so we can call them when we receive a response
response_callbacks_counter: AtomicU32,
#[allow(clippy::type_complexity)]
response_callbacks_queue: Arc<Mutex<HashMap<u32, (Box<dyn Callback>, Instant)>>>,
// Flag to track connection status - atomic for thread safety without locks
connection_status: Arc<std::sync::atomic::AtomicBool>,
}
#[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.
pub struct NativeStatus {
key: String,
value: String,
}
// In our callback management, 0 is a reserved sequence number indicating that a message does not
// have a callback.
const NO_CALLBACK_INDICATOR: u32 = 0;
#[uniffi::export]
impl MacOSProviderClient {
// FIXME: Remove unwraps! They panic and terminate the whole application.
#[allow(clippy::unwrap_used)]
#[uniffi::constructor]
pub fn connect() -> Self {
INIT.call_once(|| {
let filter = EnvFilter::builder()
// Everything logs at `INFO`
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::registry()
.with(filter)
.with(tracing_oslog::OsLogger::new(
"com.bitwarden.desktop.autofill-extension",
"default",
))
.init();
});
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 {
to_server_send,
response_callbacks_counter: AtomicU32::new(1), /* Start at 1 since 0 is reserved for
* "no callback" scenarios */
response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())),
connection_status: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
let path = desktop_core::ipc::path("af");
let queue = client.response_callbacks_queue.clone();
let connection_status = client.connection_status.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Can't create runtime");
rt.spawn(
desktop_core::ipc::client::connect(path, from_server_send, to_server_recv)
.map(|r| r.map_err(|e| e.to_string())),
);
rt.block_on(async move {
while let Some(message) = from_server_recv.recv().await {
match serde_json::from_str::<SerializedMessage>(&message) {
Ok(SerializedMessage::Command(CommandMessage::Connected)) => {
info!("Connected to server");
connection_status.store(true, std::sync::atomic::Ordering::Relaxed);
}
Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => {
info!("Disconnected from server");
connection_status.store(false, std::sync::atomic::Ordering::Relaxed);
}
Ok(SerializedMessage::Message {
sequence_number,
value,
}) => match queue.lock().unwrap().remove(&sequence_number) {
Some((cb, request_start_time)) => {
info!(
"Time to process request: {:?}",
request_start_time.elapsed()
);
match value {
Ok(value) => {
if let Err(e) = cb.complete(value) {
error!(error = %e, "Error deserializing message");
}
}
Err(e) => {
error!(error = ?e, "Error processing message");
cb.error(e)
}
}
}
None => {
error!(sequence_number, "No callback found for sequence number")
}
},
Err(e) => {
error!(error = %e, "Error deserializing message");
}
};
}
});
});
client
}
pub fn send_native_status(&self, key: String, value: String) {
let status = NativeStatus { key, value };
self.send_message(status, None);
}
pub fn prepare_passkey_registration(
&self,
request: PasskeyRegistrationRequest,
callback: Arc<dyn PreparePasskeyRegistrationCallback>,
) {
self.send_message(request, Some(Box::new(callback)));
}
pub fn prepare_passkey_assertion(
&self,
request: PasskeyAssertionRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>,
) {
self.send_message(request, Some(Box::new(callback)));
}
pub fn prepare_passkey_assertion_without_user_interface(
&self,
request: PasskeyAssertionWithoutUserInterfaceRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>,
) {
self.send_message(request, Some(Box::new(callback)));
}
pub fn get_connection_status(&self) -> ConnectionStatus {
let is_connected = self
.connection_status
.load(std::sync::atomic::Ordering::Relaxed);
if is_connected {
ConnectionStatus::Connected
} else {
ConnectionStatus::Disconnected
}
}
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "camelCase")]
enum CommandMessage {
Connected,
Disconnected,
}
#[derive(Serialize, Deserialize)]
#[serde(untagged, rename_all = "camelCase")]
enum SerializedMessage {
Command(CommandMessage),
Message {
sequence_number: u32,
value: Result<serde_json::Value, BitwardenError>,
},
}
impl MacOSProviderClient {
#[allow(clippy::unwrap_used)]
fn add_callback(&self, callback: Box<dyn Callback>) -> u32 {
let sequence_number = self
.response_callbacks_counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.response_callbacks_queue
.lock()
.expect("response callbacks queue mutex should not be poisoned")
.insert(sequence_number, (callback, Instant::now()));
sequence_number
}
#[allow(clippy::unwrap_used)]
fn send_message(
&self,
message: impl Serialize + DeserializeOwned,
callback: Option<Box<dyn Callback>>,
) {
let sequence_number = if let Some(callback) = callback {
self.add_callback(callback)
} else {
NO_CALLBACK_INDICATOR
};
let message = serde_json::to_string(&SerializedMessage::Message {
sequence_number,
value: Ok(serde_json::to_value(message).unwrap()),
})
.expect("Can't serialize message");
if let Err(e) = self.to_server_send.blocking_send(message) {
// Make sure we remove the callback from the queue if we can't send the message
if sequence_number != NO_CALLBACK_INDICATOR {
if let Some((callback, _)) = self
.response_callbacks_queue
.lock()
.expect("response callbacks queue mutex should not be poisoned")
.remove(&sequence_number)
{
callback.error(BitwardenError::Internal(format!(
"Error sending message: {e}"
)));
}
}
}
}
}

View File

@@ -1,45 +0,0 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{BitwardenError, Callback, Position, UserVerification};
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationRequest {
rp_id: String,
user_name: String,
user_handle: Vec<u8>,
client_data_hash: Vec<u8>,
user_verification: UserVerification,
supported_algorithms: Vec<i32>,
window_xy: Position,
excluded_credentials: Vec<Vec<u8>>,
}
#[derive(uniffi::Record, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationResponse {
rp_id: String,
client_data_hash: Vec<u8>,
credential_id: Vec<u8>,
attestation_object: Vec<u8>,
}
#[uniffi::export(with_foreign)]
pub trait PreparePasskeyRegistrationCallback: Send + Sync {
fn on_complete(&self, credential: PasskeyRegistrationResponse);
fn on_error(&self, error: BitwardenError);
}
impl Callback for Arc<dyn PreparePasskeyRegistrationCallback> {
fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> {
let credential = serde_json::from_value(credential)?;
PreparePasskeyRegistrationCallback::on_complete(self.as_ref(), credential);
Ok(())
}
fn error(&self, error: BitwardenError) {
PreparePasskeyRegistrationCallback::on_error(self.as_ref(), error);
}
}

View File

@@ -1,3 +0,0 @@
fn main() {
uniffi::uniffi_bindgen_main()
}

View File

@@ -15,7 +15,7 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
@IBOutlet weak var logoImageView: NSImageView!
// The IPC client to communicate with the Bitwarden desktop app
private var client: MacOsProviderClient?
private var client: AutofillProviderClient?
// Timer for checking connection status
private var connectionMonitorTimer: Timer?
@@ -25,11 +25,12 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
// This is so that we can check if the app is running, and launch it, without blocking the main thread
// Blocking the main thread caused MacOS layouting to 'fail' or at least be very delayed, which caused our getWindowPositioning code to sent 0,0.
// We also properly retry the IPC connection which sometimes would take some time to be up and running, depending on CPU load, phase of jupiters moon, etc.
private func getClient() async -> MacOsProviderClient {
private func getClient() async -> AutofillProviderClient {
if let client = self.client {
return client
}
initializeLogging()
let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
// Check if the Electron app is running
@@ -61,13 +62,13 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
// Retry connecting to the Bitwarden IPC with an increasing delay
let maxRetries = 20
let delayMs = 500
var newClient: MacOsProviderClient?
var newClient: AutofillProviderClient?
for attempt in 1...maxRetries {
logger.log("[autofill-extension] Connection attempt \(attempt)")
// Create a new client instance for each retry
newClient = MacOsProviderClient.connect()
newClient = AutofillProviderClient.connect()
try? await Task.sleep(nanoseconds: UInt64(100 * attempt + (delayMs * 1_000_000))) // Convert ms to nanoseconds
let connectionStatus = newClient!.getConnectionStatus()
@@ -129,7 +130,7 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
// If we just disconnected, try to cancel the request
if currentStatus == .disconnected {
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Bitwarden desktop app disconnected"))
self.extensionContext.cancelRequest(withError: BitwardenError.Disconnected)
}
}
}

View File

@@ -17,7 +17,7 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/macos_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = "<group>"; };
3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/autofill_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = "<group>"; };
3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BitwardenMacosProvider.swift; sourceTree = "<group>"; };
968ED08A2C52A47200FFFEE6 /* ReleaseAppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseAppStore.xcconfig; sourceTree = "<group>"; };
9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "bitwarden-icon.png"; sourceTree = "<group>"; };

View File

@@ -18,16 +18,16 @@
"scripts": {
"postinstall": "electron-rebuild",
"start": "cross-env ELECTRON_IS_DEV=0 ELECTRON_NO_UPDATER=1 electron ./build",
"build-native-macos": "cd desktop_native && ./macos_provider/build.sh && node build.js cross-platform",
"build-native-macos": "cd desktop_native && ./autofill_provider/build.sh && node build.js cross-platform",
"build-native": "cd desktop_native && node build.js",
"build": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\" \"npm run build:preload\"",
"build:dev": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main:dev\" \"npm run build:renderer:dev\" \"npm run build:preload:dev\"",
"build:preload": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name preload",
"build:preload:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload",
"build:preload:watch": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload --watch",
"build:macos-extension:mac": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mac",
"build:macos-extension:mas": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas",
"build:macos-extension:masdev": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas-dev",
"build:macos-extension:mac": "./desktop_native/autofill_provider/build.sh && node scripts/build-macos-extension.js mac",
"build:macos-extension:mas": "./desktop_native/autofill_provider/build.sh && node scripts/build-macos-extension.js mas",
"build:macos-extension:masdev": "./desktop_native/autofill_provider/build.sh && node scripts/build-macos-extension.js mas-dev",
"build:main": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main",
"build:main:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main",
"build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main --watch",