diff --git a/.vscode/settings.json b/.vscode/settings.json index 8f89bc03b8c..3d6870b2f44 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,6 @@ }, "rust-analyzer.linkedProjects": ["apps/desktop/desktop_native/Cargo.toml"], "typescript.tsdk": "node_modules/typescript/lib", - "eslint.useFlatConfig": true + "eslint.useFlatConfig": true, + "rust-analyzer.server.path": null } diff --git a/apps/desktop/desktop_native/windows_plugin_authenticator/src/com_registration.rs b/apps/desktop/desktop_native/windows_plugin_authenticator/src/com_registration.rs deleted file mode 100644 index 68e92145b77..00000000000 --- a/apps/desktop/desktop_native/windows_plugin_authenticator/src/com_registration.rs +++ /dev/null @@ -1,338 +0,0 @@ -use std::ptr; - -use base64::{engine::general_purpose::STANDARD, Engine as _}; -use windows::core::{s, ComObjectInterface, GUID, HRESULT, HSTRING, PCWSTR}; -use windows::Win32::System::Com::*; - -use crate::com_provider; -use crate::util::delay_load; -use crate::webauthn::*; -use ciborium::value::Value; - -const AUTHENTICATOR_NAME: &str = "Bitwarden Desktop"; -const CLSID: &str = "0f7dc5d9-69ce-4652-8572-6877fd695062"; -const RPID: &str = "bitwarden.com"; -const AAGUID: &str = "d548826e-79b4-db40-a3d8-11116f7e8349"; -const LOGO_SVG: &str = r##""##; - -/// Parses a UUID string (with hyphens) into bytes -fn parse_uuid_to_bytes(uuid_str: &str) -> Result, String> { - let uuid_clean = uuid_str.replace("-", ""); - if uuid_clean.len() != 32 { - return Err("Invalid UUID format".to_string()); - } - - uuid_clean - .chars() - .collect::>() - .chunks(2) - .map(|chunk| { - let hex_str: String = chunk.iter().collect(); - u8::from_str_radix(&hex_str, 16) - .map_err(|_| format!("Invalid hex character in UUID: {}", hex_str)) - }) - .collect() -} - -/// Converts a CLSID string to a GUID -pub(crate) fn parse_clsid_to_guid_str(clsid_str: &str) -> Result { - // Remove hyphens and parse as hex - let clsid_clean = clsid_str.replace("-", ""); - if clsid_clean.len() != 32 { - return Err("Invalid CLSID format".to_string()); - } - - // Convert to u128 and create GUID - let clsid_u128 = u128::from_str_radix(&clsid_clean, 16) - .map_err(|_| "Failed to parse CLSID as hex".to_string())?; - - Ok(GUID::from_u128(clsid_u128)) -} - -/// Converts the CLSID constant string to a GUID -fn parse_clsid_to_guid() -> Result { - parse_clsid_to_guid_str(CLSID) -} - -/// Generates CBOR-encoded authenticator info according to FIDO CTAP2 specifications -/// See: https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#authenticatorGetInfo -fn generate_cbor_authenticator_info() -> Result, String> { - // Parse AAGUID from string format to bytes - let aaguid_bytes = parse_uuid_to_bytes(AAGUID)?; - - // Create the authenticator info map according to CTAP2 spec - // Using Vec<(Value, Value)> because that's what ciborium::Value::Map expects - let mut authenticator_info = Vec::new(); - - // 1: versions - Array of supported FIDO versions - authenticator_info.push(( - Value::Integer(1.into()), - Value::Array(vec![ - Value::Text("FIDO_2_0".to_string()), - Value::Text("FIDO_2_1".to_string()), - ]), - )); - - // 2: extensions - Array of supported extensions (empty for now) - authenticator_info.push((Value::Integer(2.into()), Value::Array(vec![]))); - - // 3: aaguid - 16-byte AAGUID - authenticator_info.push((Value::Integer(3.into()), Value::Bytes(aaguid_bytes))); - - // 4: options - Map of supported options - let options = vec![ - (Value::Text("rk".to_string()), Value::Bool(true)), // resident key - (Value::Text("up".to_string()), Value::Bool(true)), // user presence - (Value::Text("uv".to_string()), Value::Bool(true)), // user verification - ]; - authenticator_info.push((Value::Integer(4.into()), Value::Map(options))); - - // 9: transports - Array of supported transports - authenticator_info.push(( - Value::Integer(9.into()), - Value::Array(vec![ - Value::Text("internal".to_string()), - Value::Text("hybrid".to_string()), - ]), - )); - - // 10: algorithms - Array of supported algorithms - let algorithm = vec![ - (Value::Text("alg".to_string()), Value::Integer((-7).into())), // ES256 - ( - Value::Text("type".to_string()), - Value::Text("public-key".to_string()), - ), - ]; - authenticator_info.push(( - Value::Integer(10.into()), - Value::Array(vec![Value::Map(algorithm)]), - )); - - // Encode to CBOR - let mut buffer = Vec::new(); - ciborium::ser::into_writer(&Value::Map(authenticator_info), &mut buffer) - .map_err(|e| format!("Failed to encode CBOR: {}", e))?; - - Ok(buffer) -} - -/// Initializes the COM library for use on the calling thread, -/// and registers + sets the security values. -pub fn initialize_com_library() -> std::result::Result<(), String> { - let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; - - if result.is_err() { - return Err(format!( - "Error: couldn't initialize the COM library\n{}", - result.message() - )); - } - - match unsafe { - CoInitializeSecurity( - None, - -1, - None, - None, - RPC_C_AUTHN_LEVEL_DEFAULT, - RPC_C_IMP_LEVEL_IMPERSONATE, - None, - EOAC_NONE, - None, - ) - } { - Ok(_) => Ok(()), - Err(e) => Err(format!( - "Error: couldn't initialize COM security\n{}", - e.message() - )), - } -} - -/// Registers the Bitwarden Plugin Authenticator COM library with Windows. -pub fn register_com_library() -> std::result::Result<(), String> { - static FACTORY: windows::core::StaticComObject = - com_provider::Factory.into_static(); - let clsid_guid = parse_clsid_to_guid().map_err(|e| format!("Failed to parse CLSID: {}", e))?; - let clsid: *const GUID = &clsid_guid; - - match unsafe { - CoRegisterClassObject( - clsid, - FACTORY.as_interface_ref(), - //FACTORY.as_interface::(), - CLSCTX_LOCAL_SERVER, - REGCLS_MULTIPLEUSE, - ) - } { - Ok(_) => Ok(()), - Err(e) => Err(format!( - "Error: couldn't register the COM library\n{}", - e.message() - )), - } -} - -/// Adds Bitwarden as a plugin authenticator. -pub fn add_authenticator() -> std::result::Result<(), String> { - let authenticator_name: HSTRING = AUTHENTICATOR_NAME.into(); - let authenticator_name_ptr = PCWSTR(authenticator_name.as_ptr()).as_ptr(); - - // Parse CLSID into GUID structure - let clsid_guid = - parse_clsid_to_guid().map_err(|e| format!("Failed to parse CLSID to GUID: {}", e))?; - - let relying_party_id: HSTRING = RPID.into(); - let relying_party_id_ptr = PCWSTR(relying_party_id.as_ptr()).as_ptr(); - - // Base64-encode the SVG as required by Windows API - let logo_b64: String = STANDARD.encode(LOGO_SVG); - let logo_b64_buf: Vec = logo_b64.encode_utf16().chain(std::iter::once(0)).collect(); - - // Generate CBOR authenticator info dynamically - let authenticator_info_bytes = generate_cbor_authenticator_info() - .map_err(|e| format!("Failed to generate authenticator info: {}", e))?; - - let add_authenticator_options = WebAuthnPluginAddAuthenticatorOptions { - authenticator_name: authenticator_name_ptr, - rclsid: &clsid_guid, // Changed to GUID reference - rpid: relying_party_id_ptr, - light_theme_logo_svg: logo_b64_buf.as_ptr(), - dark_theme_logo_svg: logo_b64_buf.as_ptr(), - cbor_authenticator_info_byte_count: authenticator_info_bytes.len() as u32, - cbor_authenticator_info: authenticator_info_bytes.as_ptr(), // Use as_ptr() not as_mut_ptr() - supported_rp_ids_count: 0, // NEW field: 0 means all RPs supported - supported_rp_ids: ptr::null(), // NEW field - }; - - let mut add_response_ptr: *mut WebAuthnPluginAddAuthenticatorResponse = ptr::null_mut(); - - let result = unsafe { - delay_load::( - s!("webauthn.dll"), - s!("WebAuthNPluginAddAuthenticator"), // Stable function name - ) - }; - - match result { - Some(api) => { - let result = unsafe { api(&add_authenticator_options, &mut add_response_ptr) }; - - if result.is_err() { - return Err(format!( - "Error: Error response from WebAuthNPluginAddAuthenticator()\n{}", - result.message() - )); - } - - // Free the response if needed - if !add_response_ptr.is_null() { - free_add_authenticator_response(add_response_ptr); - } - - Ok(()) - }, - None => { - Err(String::from("Error: Can't complete add_authenticator(), as the function WebAuthNPluginAddAuthenticator can't be found.")) - } - } -} - -fn free_add_authenticator_response(response: *mut WebAuthnPluginAddAuthenticatorResponse) { - let result = unsafe { - delay_load::( - s!("webauthn.dll"), - s!("WebAuthNPluginFreeAddAuthenticatorResponse"), - ) - }; - - if let Some(api) = result { - unsafe { api(response) }; - } -} - -type WebAuthNPluginAddAuthenticatorFnDeclaration = unsafe extern "cdecl" fn( - pPluginAddAuthenticatorOptions: *const WebAuthnPluginAddAuthenticatorOptions, - ppPluginAddAuthenticatorResponse: *mut *mut WebAuthnPluginAddAuthenticatorResponse, -) -> HRESULT; - -type WebAuthNPluginFreeAddAuthenticatorResponseFnDeclaration = unsafe extern "cdecl" fn( - pPluginAddAuthenticatorResponse: *mut WebAuthnPluginAddAuthenticatorResponse, -); - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_generate_cbor_authenticator_info() { - let result = generate_cbor_authenticator_info(); - assert!(result.is_ok(), "CBOR generation should succeed"); - - let cbor_bytes = result.unwrap(); - assert!(!cbor_bytes.is_empty(), "CBOR bytes should not be empty"); - - // Verify the CBOR can be decoded back - let decoded: Result = ciborium::de::from_reader(&cbor_bytes[..]); - assert!(decoded.is_ok(), "Generated CBOR should be valid"); - - // Verify it's a map with expected keys - if let Value::Map(map) = decoded.unwrap() { - assert!( - map.iter().any(|(k, _)| k == &Value::Integer(1.into())), - "Should contain versions (key 1)" - ); - assert!( - map.iter().any(|(k, _)| k == &Value::Integer(2.into())), - "Should contain extensions (key 2)" - ); - assert!( - map.iter().any(|(k, _)| k == &Value::Integer(3.into())), - "Should contain aaguid (key 3)" - ); - assert!( - map.iter().any(|(k, _)| k == &Value::Integer(4.into())), - "Should contain options (key 4)" - ); - assert!( - map.iter().any(|(k, _)| k == &Value::Integer(9.into())), - "Should contain transports (key 9)" - ); - assert!( - map.iter().any(|(k, _)| k == &Value::Integer(10.into())), - "Should contain algorithms (key 10)" - ); - } else { - panic!("CBOR should decode to a map"); - } - - // Print the generated CBOR for verification - println!("Generated CBOR hex: {}", hex::encode(&cbor_bytes)); - } - - #[test] - fn test_aaguid_parsing() { - let result = parse_uuid_to_bytes(AAGUID); - assert!(result.is_ok(), "AAGUID parsing should succeed"); - - let aaguid_bytes = result.unwrap(); - assert_eq!(aaguid_bytes.len(), 16, "AAGUID should be 16 bytes"); - assert_eq!(aaguid_bytes[0], 0xd5, "First byte should be 0xd5"); - assert_eq!(aaguid_bytes[1], 0x48, "Second byte should be 0x48"); - - // Verify full expected AAGUID - let expected_hex = "d548826e79b4db40a3d811116f7e8349"; - let expected_bytes = hex::decode(expected_hex).unwrap(); - assert_eq!( - aaguid_bytes, expected_bytes, - "AAGUID should match expected value" - ); - } - - #[test] - fn test_parse_clsid_to_guid() { - let result = parse_clsid_to_guid(); - assert!(result.is_ok(), "CLSID parsing should succeed"); - } -} diff --git a/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs b/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs index 73c085ef83b..9aa975f08dc 100644 --- a/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs +++ b/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs @@ -6,31 +6,76 @@ mod assert; mod com_buffer; mod com_provider; -mod com_registration; mod ipc2; mod make_credential; mod types; mod util; mod webauthn; +mod win_webauthn; + +use std::collections::HashSet; // Re-export main functionality -pub use com_registration::{add_authenticator, initialize_com_library, register_com_library}; pub use types::UserVerificationRequirement; +use win_webauthn::{PluginAddAuthenticatorOptions, WebAuthnPlugin}; + +use crate::win_webauthn::{AuthenticatorInfo, CtapVersion, PublicKeyCredentialParameters}; + +const AUTHENTICATOR_NAME: &str = "Bitwarden Desktop"; +const RPID: &str = "bitwarden.com"; +const CLSID: &str = "0f7dc5d9-69ce-4652-8572-6877fd695062"; +const AAGUID: &str = "d548826e-79b4-db40-a3d8-11116f7e8349"; +const LOGO_SVG: &str = r##""##; + /// Handles initialization and registration for the Bitwarden desktop app as a /// For now, also adds the authenticator pub fn register() -> std::result::Result<(), String> { // TODO: Can we spawn a new named thread for debugging? tracing::debug!("register() called..."); - let r = com_registration::initialize_com_library(); - tracing::debug!("Initialized the com library: {:?}", r); + let r = WebAuthnPlugin::initialize(); + tracing::debug!( + "Initialized the com library with WebAuthnPlugin::initialize(): {:?}", + r + ); - let r = com_registration::register_com_library(); + let clsid = CLSID.try_into().expect("valid GUID string"); + let plugin = WebAuthnPlugin::new(clsid); + let r = plugin.register_server(); tracing::debug!("Registered the com library: {:?}", r); - let r = com_registration::add_authenticator(); - tracing::debug!("Added the authenticator: {:?}", r); + tracing::debug!("Parsing authenticator options"); + let aaguid = AAGUID + .try_into() + .map_err(|err| format!("Invalid AAGUID `{AAGUID}`: {err}"))?; + let options = PluginAddAuthenticatorOptions { + authenticator_name: AUTHENTICATOR_NAME.to_string(), + clsid, + rp_id: Some(RPID.to_string()), + light_theme_logo_svg: Some(LOGO_SVG.to_string()), + dark_theme_logo_svg: Some(LOGO_SVG.to_string()), + authenticator_info: AuthenticatorInfo { + versions: HashSet::from([CtapVersion::Fido2_0, CtapVersion::Fido2_1]), + aaguid: aaguid, + options: Some(HashSet::from([ + "rk".to_string(), + "up".to_string(), + "uv".to_string(), + ])), + transports: Some(HashSet::from([ + "internal".to_string(), + "hybrid".to_string(), + ])), + algorithms: Some(vec![PublicKeyCredentialParameters { + alg: -7, + typ: "public-key".to_string(), + }]), + }, + supported_rp_ids: None, + }; + let response = WebAuthnPlugin::add_authenticator(options); + tracing::debug!("Added the authenticator: {response:?}"); Ok(()) } diff --git a/apps/desktop/desktop_native/windows_plugin_authenticator/src/win_webauthn/com.rs b/apps/desktop/desktop_native/windows_plugin_authenticator/src/win_webauthn/com.rs new file mode 100644 index 00000000000..7a180c4f300 --- /dev/null +++ b/apps/desktop/desktop_native/windows_plugin_authenticator/src/win_webauthn/com.rs @@ -0,0 +1,90 @@ +//! Functions for interacting with Windows COM. + +use std::ptr; + +use windows::{ + core::{implement, ComObjectInterface, IUnknown, GUID}, + Win32::System::Com::*, +}; + +use super::{ErrorKind, WinWebAuthnError}; + +#[implement(IClassFactory)] +pub struct Factory; + +impl IClassFactory_Impl for Factory_Impl { + fn CreateInstance( + &self, + _outer: windows::core::Ref, + _iid: *const windows::core::GUID, + _object: *mut *mut core::ffi::c_void, + ) -> windows::core::Result<()> { + unimplemented!() + } + + fn LockServer(&self, _lock: windows::core::BOOL) -> windows::core::Result<()> { + unimplemented!() + } +} + +/// Registers the plugin authenticator COM library with Windows. +pub(super) fn register_server(clsid: &GUID) -> Result<(), WinWebAuthnError> { + static FACTORY: windows::core::StaticComObject = + crate::com_provider::Factory.into_static(); + unsafe { + CoRegisterClassObject( + ptr::from_ref(clsid), + FACTORY.as_interface_ref(), + CLSCTX_LOCAL_SERVER, + REGCLS_MULTIPLEUSE, + ) + } + .map_err(|err| { + WinWebAuthnError::with_cause( + ErrorKind::WindowsInternal, + "Couldn't register the COM library with Windows", + err, + ) + })?; + Ok(()) +} + +/// Initializes the COM library for use on the calling thread, +/// and registers + sets the security values. +pub(super) fn initialize() -> std::result::Result<(), WinWebAuthnError> { + let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + + if result.is_err() { + return Err(WinWebAuthnError::with_cause( + ErrorKind::WindowsInternal, + "Could not initialize the COM library", + windows::core::Error::from_hresult(result), + )); + } + + unsafe { + CoInitializeSecurity( + None, + -1, + None, + None, + RPC_C_AUTHN_LEVEL_DEFAULT, + RPC_C_IMP_LEVEL_IMPERSONATE, + None, + EOAC_NONE, + None, + ) + } + .map_err(|err| { + WinWebAuthnError::with_cause( + ErrorKind::WindowsInternal, + "Could not initialize COM security", + err, + ) + }) +} + +pub(super) fn uninitialize() -> std::result::Result<(), WinWebAuthnError> { + unsafe { CoUninitialize() }; + Ok(()) +} diff --git a/apps/desktop/desktop_native/windows_plugin_authenticator/src/win_webauthn/mod.rs b/apps/desktop/desktop_native/windows_plugin_authenticator/src/win_webauthn/mod.rs new file mode 100644 index 00000000000..879342ca01f --- /dev/null +++ b/apps/desktop/desktop_native/windows_plugin_authenticator/src/win_webauthn/mod.rs @@ -0,0 +1,208 @@ +mod com; +mod types; +mod util; + +use std::{error::Error, fmt::Display, ptr::NonNull}; + +use windows::core::GUID; + +pub use types::{ + AuthenticatorInfo, CtapVersion, PluginAddAuthenticatorOptions, PublicKeyCredentialParameters, +}; + +use crate::win_webauthn::{ + types::{ + webauthn_plugin_add_authenticator, PluginAddAuthenticatorResponse, + WebAuthnPluginAddAuthenticatorResponse, WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS, + }, + util::WindowsString, +}; + +#[derive(Clone, Copy)] +pub struct Clsid(GUID); + +impl TryFrom<&str> for Clsid { + type Error = WinWebAuthnError; + + fn try_from(value: &str) -> Result { + // Remove hyphens and parse as hex + let clsid_clean = value.replace("-", "").replace("{", "").replace("}", ""); + if clsid_clean.len() != 32 { + return Err(WinWebAuthnError::new( + ErrorKind::Serialization, + "Invalid CLSID format", + )); + } + + // Convert to u128 and create GUID + let clsid_u128 = u128::from_str_radix(&clsid_clean, 16).map_err(|err| { + WinWebAuthnError::with_cause( + ErrorKind::Serialization, + "Failed to parse CLSID as hex", + err, + ) + })?; + + let clsid = Clsid(GUID::from_u128(clsid_u128)); + Ok(clsid) + } +} + +pub struct WebAuthnPlugin { + clsid: Clsid, +} + +impl WebAuthnPlugin { + pub fn new(clsid: Clsid) -> Self { + WebAuthnPlugin { clsid } + } + + /// Registers a COM server with Windows. + /// + /// This only needs to be called on installation of your application. + pub fn register_server(&self) -> Result<(), WinWebAuthnError> { + com::register_server(&self.clsid.0) + } + + /// Initializes the COM library for use on the calling thread, + /// and registers + sets the security values. + pub fn initialize() -> Result<(), WinWebAuthnError> { + com::initialize() + } + + /// Adds this implementation as a Windows WebAuthn plugin. + /// + /// This only needs to be called on installation of your application. + pub fn add_authenticator( + options: PluginAddAuthenticatorOptions, + ) -> Result { + let mut response_ptr: *mut WebAuthnPluginAddAuthenticatorResponse = std::ptr::null_mut(); + + // We need to be careful to use .as_ref() to ensure that we're not + // sending dangling pointers to API. + let authenticator_name = options.authenticator_name.to_utf16(); + + let rp_id = options.rp_id.as_ref().map(|rp_id| rp_id.to_utf16()); + let pwszPluginRpId = rp_id.as_ref().map_or(std::ptr::null(), |v| v.as_ptr()); + + let light_logo_b64 = options.light_theme_logo_b64(); + let pwszLightThemeLogoSvg = light_logo_b64 + .as_ref() + .map_or(std::ptr::null(), |v| v.as_ptr()); + let dark_logo_b64 = options.dark_theme_logo_b64(); + let pwszDarkThemeLogoSvg = dark_logo_b64 + .as_ref() + .map_or(std::ptr::null(), |v| v.as_ptr()); + + let authenticator_info = options.authenticator_info.as_ctap_bytes()?; + + let supported_rp_ids: Option>> = options + .supported_rp_ids + .map(|ids| ids.iter().map(|id| id.to_utf16()).collect()); + let supported_rp_id_ptrs: Option> = supported_rp_ids + .as_ref() + .map(|ids| ids.iter().map(Vec::as_ptr).collect()); + let pbSupportedRpIds = supported_rp_id_ptrs + .as_ref() + .map_or(std::ptr::null(), |v| v.as_ptr()); + + let options_c = WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS { + pwszAuthenticatorName: authenticator_name.as_ptr(), + rclsid: &options.clsid.0, + pwszPluginRpId, + pwszLightThemeLogoSvg, + pwszDarkThemeLogoSvg, + cbAuthenticatorInfo: authenticator_info.len() as u32, + pbAuthenticatorInfo: authenticator_info.as_ptr(), + cSupportedRpIds: supported_rp_id_ptrs.map_or(0, |ids| ids.len() as u32), + pbSupportedRpIds, + }; + let result = webauthn_plugin_add_authenticator(&options_c, &mut response_ptr)?; + result.ok().map_err(|err| { + WinWebAuthnError::with_cause( + ErrorKind::WindowsInternal, + "Failed to add authenticator", + err, + ) + })?; + + if let Some(response) = NonNull::new(response_ptr) { + Ok(response.into()) + } else { + Err(WinWebAuthnError::new( + ErrorKind::WindowsInternal, + "WebAuthNPluginAddAuthenticatorResponse returned null", + )) + } + } +} + +#[derive(Debug)] +pub struct WinWebAuthnError { + kind: ErrorKind, + description: Option, + cause: Option>, +} + +impl WinWebAuthnError { + pub(crate) fn new(kind: ErrorKind, description: &str) -> Self { + Self { + kind, + description: Some(description.to_string()), + cause: None, + } + } + + pub(crate) fn with_cause( + kind: ErrorKind, + description: &str, + cause: E, + ) -> Self { + let cause: Box = Box::new(cause); + Self { + kind, + description: Some(description.to_string()), + cause: Some(cause), + } + } +} + +#[derive(Debug)] +pub enum ErrorKind { + DllLoad, + Serialization, + WindowsInternal, +} + +impl Display for WinWebAuthnError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let msg = match self.kind { + ErrorKind::Serialization => "Failed to serialize data", + ErrorKind::DllLoad => "Failed to load function from DLL", + ErrorKind::WindowsInternal => "A Windows error occurred", + }; + f.write_str(msg)?; + if let Some(d) = &self.description { + write!(f, ": {d}")?; + } + if let Some(e) = &self.cause { + write!(f, ". Caused by: {e}")?; + } + Ok(()) + } +} + +impl Error for WinWebAuthnError {} + +#[cfg(test)] +mod tests { + use super::Clsid; + + const CLSID: &str = "0f7dc5d9-69ce-4652-8572-6877fd695062"; + + #[test] + fn test_parse_clsid_to_guid() { + let result = Clsid::try_from(CLSID); + assert!(result.is_ok(), "CLSID parsing should succeed"); + } +} diff --git a/apps/desktop/desktop_native/windows_plugin_authenticator/src/win_webauthn/types.rs b/apps/desktop/desktop_native/windows_plugin_authenticator/src/win_webauthn/types.rs new file mode 100644 index 00000000000..5b1291ad0f8 --- /dev/null +++ b/apps/desktop/desktop_native/windows_plugin_authenticator/src/win_webauthn/types.rs @@ -0,0 +1,432 @@ +//! Types and functions defined in the Windows WebAuthn API. + +use std::{collections::HashSet, ptr::NonNull}; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use ciborium::Value; +use windows::{ + core::{GUID, HRESULT}, + Win32::System::LibraryLoader::GetProcAddress, +}; +use windows_core::s; + +use crate::win_webauthn::{util::WindowsString, Clsid, ErrorKind, WinWebAuthnError}; + +macro_rules! webauthn_call { + ($symbol:literal as fn $fn_name:ident($($arg:ident: $arg_type:ty,)+) -> $result_type:ty) => ( + pub(super) fn $fn_name($($arg: $arg_type),*) -> Result<$result_type, WinWebAuthnError> { + let library = super::util::load_webauthn_lib()?; + let response = unsafe { + let address = GetProcAddress(library, s!($symbol)).ok_or( + WinWebAuthnError::new( + ErrorKind::DllLoad, + &format!( + "Failed to load function {}", + $symbol + ), + ), + )?; + + let function: unsafe extern "cdecl" fn( + $($arg: $arg_type),* + ) -> $result_type = std::mem::transmute_copy(&address); + function($($arg),*) + }; + super::util::free_webauthn_lib(library)?; + Ok(response) + } + ) +} + +/// Used when adding a Windows plugin authenticator (stable API). +/// Header File Name: _WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS +/// Header File Usage: WebAuthNPluginAddAuthenticator() +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub(super) struct WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS { + /// Authenticator Name + pub(super) pwszAuthenticatorName: *const u16, + + /// Plugin COM ClsId + pub(super) rclsid: *const GUID, + + /// Plugin RPID + /// + /// Required for a nested WebAuthN call originating from a plugin. + pub(super) pwszPluginRpId: *const u16, + + /// Plugin Authenticator Logo for the Light themes. base64-encoded SVG 1.1 + /// + /// The data should be encoded as `UTF16(BASE64(UTF8(svg_text)))`. + pub(super) pwszLightThemeLogoSvg: *const u16, + + /// Plugin Authenticator Logo for the Dark themes. base64-encoded SVG 1.1 + /// + /// The data should be encoded as `UTF16(BASE64(UTF8(svg_text)))`. + pub(super) pwszDarkThemeLogoSvg: *const u16, + + pub(super) cbAuthenticatorInfo: u32, + /// CTAP CBOR-encoded authenticatorGetInfo output + pub(super) pbAuthenticatorInfo: *const u8, + + pub(super) cSupportedRpIds: u32, + /// List of supported RP IDs (Relying Party IDs). + /// + /// Should be null if all RPs are supported. + pub(super) pbSupportedRpIds: *const *const u16, +} + +pub struct PluginAddAuthenticatorOptions { + /// Authenticator Name + pub authenticator_name: String, + + /// Plugin COM ClsId + pub clsid: Clsid, + + /// Plugin RPID + /// + /// Required for a nested WebAuthN call originating from a plugin. + pub rp_id: Option, + + /// Plugin Authenticator Logo for the Light themes. + /// + /// String should contain a valid SVG 1.1 document. + pub light_theme_logo_svg: Option, + + // Plugin Authenticator Logo for the Dark themes. Bytes of SVG 1.1. + /// + /// String should contain a valid SVG 1.1 element. + pub dark_theme_logo_svg: Option, + + /// CTAP authenticatorGetInfo values + pub authenticator_info: AuthenticatorInfo, + + /// List of supported RP IDs (Relying Party IDs). + /// + /// Should be [None] if all RPs are supported. + pub supported_rp_ids: Option>, +} + +impl PluginAddAuthenticatorOptions { + pub fn light_theme_logo_b64(&self) -> Option> { + self.light_theme_logo_svg + .as_ref() + .map(|svg| Self::encode_svg(&svg)) + } + + pub fn dark_theme_logo_b64(&self) -> Option> { + self.dark_theme_logo_svg + .as_ref() + .map(|svg| Self::encode_svg(&svg)) + } + + fn encode_svg(svg: &str) -> Vec { + let logo_b64: String = STANDARD.encode(svg); + logo_b64.to_utf16() + } +} + +/// Used as a response type when adding a Windows plugin authenticator. +/// Header File Name: _WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE +/// Header File Usage: WebAuthNPluginAddAuthenticator() +/// WebAuthNPluginFreeAddAuthenticatorResponse() +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub(super) struct WebAuthnPluginAddAuthenticatorResponse { + pub plugin_operation_signing_key_byte_count: u32, + pub plugin_operation_signing_key: *mut u8, +} + +type WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE = WebAuthnPluginAddAuthenticatorResponse; + +/// Safe wrapper around [WebAuthnPluginAddAuthenticatorResponse] +#[derive(Debug)] +pub struct PluginAddAuthenticatorResponse { + inner: NonNull, +} + +impl PluginAddAuthenticatorResponse { + pub fn plugin_operation_signing_key(&self) -> &[u8] { + unsafe { + let p = &*self.inner.as_ptr(); + std::slice::from_raw_parts( + p.plugin_operation_signing_key, + p.plugin_operation_signing_key_byte_count as usize, + ) + } + } +} + +impl From> for PluginAddAuthenticatorResponse { + fn from(value: NonNull) -> Self { + Self { inner: value } + } +} + +impl Drop for PluginAddAuthenticatorResponse { + fn drop(&mut self) { + unsafe { + // SAFETY: This should only fail if: + // - we cannot load the webauthn.dll, which we already have if we have constructed this type, or + // - we spelled the function wrong, which is a library error. + webauthn_plugin_free_add_authenticator_response(self.inner.as_mut()) + .expect("function to load properly"); + } + } +} + +webauthn_call!("WebAuthNPluginAddAuthenticator" as +fn webauthn_plugin_add_authenticator( + pPluginAddAuthenticatorOptions: *const WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS, + ppPluginAddAuthenticatorResponse: *mut *mut WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE, +) -> HRESULT); + +webauthn_call!("WebAuthNPluginFreeAddAuthenticatorResponse" as +fn webauthn_plugin_free_add_authenticator_response( + pPluginAddAuthenticatorOptions: *mut WebAuthnPluginAddAuthenticatorResponse, +) -> ()); + +/// List of its supported protocol versions and extensions, its AAGUID, and +/// other aspects of its overall capabilities. +pub struct AuthenticatorInfo { + /// List of supported versions. + pub versions: HashSet, + + /// The claimed AAGUID. 16 bytes in length and encoded the same as + /// MakeCredential AuthenticatorData, as specified in [WebAuthn]. + /// + /// Note: even though the name has "guid" in it, this is actually an RFC4122 + /// UUID, which is serialized differently than a Windows GUID. + pub aaguid: Uuid, + + /// List of supported options. + pub options: Option>, + + /// List of supported transports. Values are taken from the + /// AuthenticatorTransport enum in [WebAuthn]. The list MUST NOT include + /// duplicate values nor be empty if present. Platforms MUST tolerate + /// unknown values. + pub transports: Option>, + + /// List of supported algorithms for credential generation, as specified in + /// [WebAuthn]. The array is ordered from most preferred to least preferred + /// and MUST NOT include duplicate entries nor be empty if present. + /// PublicKeyCredentialParameters' algorithm identifiers are values that + /// SHOULD be registered in the IANA COSE Algorithms registry + /// [IANA-COSE-ALGS-REG]. + pub algorithms: Option>, +} + +impl AuthenticatorInfo { + pub fn as_ctap_bytes(&self) -> Result, super::WinWebAuthnError> { + // Create the authenticator info map according to CTAP2 spec + // Using Vec<(Value, Value)> because that's what ciborium::Value::Map expects + let mut authenticator_info = Vec::new(); + + // 1: versions - Array of supported FIDO versions + let versions = self + .versions + .iter() + .map(|v| Value::Text(v.into())) + .collect(); + authenticator_info.push((Value::Integer(1.into()), Value::Array(versions))); + + // 2: extensions - Array of supported extensions (empty for now) + authenticator_info.push((Value::Integer(2.into()), Value::Array(vec![]))); + + // 3: aaguid - 16-byte AAGUID + authenticator_info.push(( + Value::Integer(3.into()), + Value::Bytes(self.aaguid.0.to_vec()), + )); + + // 4: options - Map of supported options + if let Some(options) = &self.options { + let options = options + .iter() + .map(|o| (Value::Text(o.into()), Value::Bool(true))) + .collect(); + authenticator_info.push((Value::Integer(4.into()), Value::Map(options))); + } + + // 9: transports - Array of supported transports + if let Some(transports) = &self.transports { + let transports = transports.iter().map(|t| Value::Text(t.clone())).collect(); + authenticator_info.push((Value::Integer(9.into()), Value::Array(transports))); + } + + // 10: algorithms - Array of supported algorithms + if let Some(algorithms) = &self.algorithms { + let algorithms: Vec = algorithms + .iter() + .map(|a| { + Value::Map(vec![ + (Value::Text("alg".to_string()), Value::Integer(a.alg.into())), + (Value::Text("type".to_string()), Value::Text(a.typ.clone())), + ]) + }) + .collect(); + authenticator_info.push((Value::Integer(10.into()), Value::Array(algorithms))); + } + + // Encode to CBOR + let mut buffer = Vec::new(); + ciborium::ser::into_writer(&Value::Map(authenticator_info), &mut buffer).map_err(|e| { + WinWebAuthnError::with_cause( + ErrorKind::Serialization, + "Failed to serialize authenticator info into CBOR", + e, + ) + })?; + + Ok(buffer) + } +} + +// A UUID is not the same as a Windows GUID +/// An RFC4122 UUID. +pub struct Uuid([u8; 16]); + +impl TryFrom<&str> for Uuid { + type Error = WinWebAuthnError; + + fn try_from(value: &str) -> Result { + let uuid_clean = value.replace("-", "").replace("{", "").replace("}", ""); + if uuid_clean.len() != 32 { + return Err(WinWebAuthnError::new( + ErrorKind::Serialization, + "Invalid UUID format", + )); + } + + let bytes = uuid_clean + .chars() + .collect::>() + .chunks(2) + .map(|chunk| { + let hex_str: String = chunk.iter().collect(); + u8::from_str_radix(&hex_str, 16).map_err(|_| { + WinWebAuthnError::new( + ErrorKind::Serialization, + &format!("Invalid hex character in UUID: {}", hex_str), + ) + }) + }) + .collect::, WinWebAuthnError>>()?; + + // SAFETY: We already checked the length of the string before, so this should result in the correct number of bytes. + let b: [u8; 16] = bytes.try_into().expect("16 bytes to be parsed"); + Ok(Uuid(b)) + } +} + +#[derive(Hash, Eq, PartialEq)] +pub enum CtapVersion { + Fido2_0, + Fido2_1, +} + +pub struct PublicKeyCredentialParameters { + pub alg: i32, + pub typ: String, +} + +impl From<&CtapVersion> for String { + fn from(value: &CtapVersion) -> Self { + match value { + CtapVersion::Fido2_0 => "FIDO_2_0", + CtapVersion::Fido2_1 => "FIDO_2_1", + } + .to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const AAGUID: &str = "d548826e-79b4-db40-a3d8-11116f7e8349"; + #[test] + fn test_generate_cbor_authenticator_info() { + let aaguid = Uuid::try_from(AAGUID).unwrap(); + let authenticator_info = AuthenticatorInfo { + versions: HashSet::from([CtapVersion::Fido2_0, CtapVersion::Fido2_1]), + aaguid: aaguid, + options: Some(HashSet::from([ + "rk".to_string(), + "up".to_string(), + "uv".to_string(), + ])), + transports: Some(HashSet::from([ + "internal".to_string(), + "hybrid".to_string(), + ])), + algorithms: Some(vec![PublicKeyCredentialParameters { + alg: -7, + typ: "public-key".to_string(), + }]), + }; + let result = authenticator_info.as_ctap_bytes(); + assert!(result.is_ok(), "CBOR generation should succeed"); + + let cbor_bytes = result.unwrap(); + assert!(!cbor_bytes.is_empty(), "CBOR bytes should not be empty"); + + // Verify the CBOR can be decoded back + let decoded: Result = ciborium::de::from_reader(&cbor_bytes[..]); + assert!(decoded.is_ok(), "Generated CBOR should be valid"); + + // Verify it's a map with expected keys + if let Value::Map(map) = decoded.unwrap() { + assert!( + map.iter().any(|(k, _)| k == &Value::Integer(1.into())), + "Should contain versions (key 1)" + ); + assert!( + map.iter().any(|(k, _)| k == &Value::Integer(2.into())), + "Should contain extensions (key 2)" + ); + assert!( + map.iter().any(|(k, _)| k == &Value::Integer(3.into())), + "Should contain aaguid (key 3)" + ); + assert!( + map.iter().any(|(k, _)| k == &Value::Integer(4.into())), + "Should contain options (key 4)" + ); + assert!( + map.iter().any(|(k, _)| k == &Value::Integer(9.into())), + "Should contain transports (key 9)" + ); + assert!( + map.iter().any(|(k, _)| k == &Value::Integer(10.into())), + "Should contain algorithms (key 10)" + ); + } else { + panic!("CBOR should decode to a map"); + } + + // Print the generated CBOR for verification + println!("Generated CBOR hex: {}", hex::encode(&cbor_bytes)); + } + + #[test] + fn test_aaguid_parsing() { + let result = Uuid::try_from(AAGUID); + assert!(result.is_ok(), "AAGUID parsing should succeed"); + + let aaguid_bytes = result.unwrap(); + assert_eq!(aaguid_bytes.0.len(), 16, "AAGUID should be 16 bytes"); + assert_eq!(aaguid_bytes.0[0], 0xd5, "First byte should be 0xd5"); + assert_eq!(aaguid_bytes.0[1], 0x48, "Second byte should be 0x48"); + + // Verify full expected AAGUID + let expected_hex = "d548826e79b4db40a3d811116f7e8349"; + let expected_bytes = hex::decode(expected_hex).unwrap(); + assert_eq!( + &aaguid_bytes.0[..], + expected_bytes, + "AAGUID should match expected value" + ); + } +} diff --git a/apps/desktop/desktop_native/windows_plugin_authenticator/src/win_webauthn/util.rs b/apps/desktop/desktop_native/windows_plugin_authenticator/src/win_webauthn/util.rs new file mode 100644 index 00000000000..6f5807aecc4 --- /dev/null +++ b/apps/desktop/desktop_native/windows_plugin_authenticator/src/win_webauthn/util.rs @@ -0,0 +1,40 @@ +use windows::{ + core::s, + Win32::{ + Foundation::{FreeLibrary, HMODULE}, + System::LibraryLoader::{LoadLibraryExA, LOAD_LIBRARY_SEARCH_SYSTEM32}, + }, +}; + +use crate::win_webauthn::{ErrorKind, WinWebAuthnError}; + +pub(super) fn load_webauthn_lib() -> Result { + unsafe { + LoadLibraryExA(s!("webauthn.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32).map_err(|err| { + WinWebAuthnError::with_cause(ErrorKind::DllLoad, "Failed to load webauthn.dll", err) + }) + } +} + +pub(super) fn free_webauthn_lib(library: HMODULE) -> Result<(), WinWebAuthnError> { + unsafe { + FreeLibrary(library).map_err(|err| { + WinWebAuthnError::with_cause( + ErrorKind::WindowsInternal, + "Failed to free webauthn.dll library", + err, + ) + }) + } +} + +pub(super) trait WindowsString { + fn to_utf16(&self) -> Vec; +} + +impl WindowsString for str { + fn to_utf16(&self) -> Vec { + // null-terminated UTF-16 + self.encode_utf16().chain(std::iter::once(0)).collect() + } +} diff --git a/clients.code-workspace b/clients.code-workspace index f7d86d2a242..1cbae0be20d 100644 --- a/clients.code-workspace +++ b/clients.code-workspace @@ -66,6 +66,7 @@ "typescript.preferences.importModuleSpecifier": "project-relative", "javascript.preferences.importModuleSpecifier": "project-relative", "typescript.tsdk": "root/node_modules/typescript/lib", + "rust-analyzer.cargo.target": "aarch64-pc-windows-msvc", }, "extensions": { "recommendations": [