diff --git a/Cargo.toml b/Cargo.toml index 30996c574..a8bbe0f04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,11 @@ objc2-foundation = { version = "0.3.0", default-features = false, features = [ "NSValue", "NSRange", "NSRunLoop", + "NSURLAuthenticationChallenge", + "NSURLCredential", + "NSURLProtectionSpace", + "NSURLSession", + "NSArray", ] } [target.'cfg(target_os = "ios")'.dependencies] diff --git a/src/lib.rs b/src/lib.rs index a4e1d201a..d487c4d4b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -808,6 +808,39 @@ struct WebViewAttributes<'a> { /// behavior will be disabled. /// - **macOS / Linux / Android / iOS**: Unsupported and ignored. pub general_autofill_enabled: bool, + + /// PKCS#12 client certificate data for mTLS (mutual TLS) authentication. + /// + /// When set, the WebView will present this client certificate during TLS handshakes + /// that request client authentication. The data should be a valid PKCS#12 (.p12/.pfx) + /// bundle containing the client certificate and private key. + /// + /// The certificate is extracted in memory via platform-specific APIs during + /// authentication challenges. No keychain or certificate store access is needed. + /// + /// ## Platform-specific + /// + /// - **macOS / iOS**: Uses `SecPKCS12Import` in the `WKNavigationDelegate`. + /// - **Windows / Linux / Android**: Not yet supported. + pub client_certificate_p12: Option>, + + /// Password for the PKCS#12 client certificate bundle. + /// + /// Required when [`client_certificate_p12`](Self::client_certificate_p12) is set. + /// Use an empty string if the bundle has no password. + pub client_certificate_password: Option, + + /// DER-encoded CA certificate for server trust pinning. + /// + /// When set, the WebView will trust servers presenting certificates signed by this CA, + /// even if the CA is not in the system trust store. This is useful for self-signed + /// certificates without requiring system-level certificate installation or user prompts. + /// + /// ## Platform-specific + /// + /// - **macOS / iOS**: Uses `SecTrustSetAnchorCertificates` in the `WKNavigationDelegate`. + /// - **Windows / Linux / Android**: Not yet supported. + pub trusted_ca_certificate: Option>, } impl Default for WebViewAttributes<'_> { @@ -851,6 +884,9 @@ impl Default for WebViewAttributes<'_> { background_throttling: None, javascript_disabled: false, general_autofill_enabled: true, + client_certificate_p12: None, + client_certificate_password: None, + trusted_ca_certificate: None, } } } @@ -1444,6 +1480,42 @@ impl<'a> WebViewBuilder<'a> { self } + /// Set a PKCS#12 (.p12/.pfx) client certificate for mTLS authentication. + /// + /// The WebView will present this certificate when a server requests client + /// authentication during the TLS handshake. The bundle should contain the + /// client certificate and its private key. The certificate is extracted + /// in memory; no keychain or certificate store access is needed. + /// + /// ## Platform-specific + /// + /// - **macOS / iOS**: Handled via `SecPKCS12Import` in the navigation delegate. + /// - **Windows / Linux / Android**: Not yet supported, data is stored for future use. + pub fn with_client_certificate( + mut self, + p12_data: impl Into>, + password: impl Into, + ) -> Self { + self.attrs.client_certificate_p12 = Some(p12_data.into()); + self.attrs.client_certificate_password = Some(password.into()); + self + } + + /// Set a DER-encoded CA certificate for server trust pinning. + /// + /// The WebView will trust servers presenting certificates signed by this CA, + /// even if the CA is not in the system trust store. This avoids the need for + /// system-level certificate installation or user authentication prompts. + /// + /// ## Platform-specific + /// + /// - **macOS / iOS**: Handled via `SecTrustSetAnchorCertificates` in the navigation delegate. + /// - **Windows / Linux / Android**: Not yet supported, data is stored for future use. + pub fn with_trusted_ca(mut self, der_data: impl Into>) -> Self { + self.attrs.trusted_ca_certificate = Some(der_data.into()); + self + } + /// Consume the builder and create the [`WebView`] from a type that implements [`HasWindowHandle`]. /// /// # Platform-specific: diff --git a/src/wkwebview/class/wry_navigation_delegate.rs b/src/wkwebview/class/wry_navigation_delegate.rs index 31d64704f..ee8fa6a87 100644 --- a/src/wkwebview/class/wry_navigation_delegate.rs +++ b/src/wkwebview/class/wry_navigation_delegate.rs @@ -6,6 +6,10 @@ use std::sync::{Arc, Mutex}; use objc2::{define_class, msg_send, rc::Retained, runtime::NSObject, MainThreadOnly}; use objc2_foundation::{MainThreadMarker, NSObjectProtocol}; +#[cfg(target_os = "macos")] +use objc2_foundation::{ + NSURLAuthenticationChallenge, NSURLCredential, NSURLSessionAuthChallengeDisposition, +}; use objc2_web_kit::{ WKDownload, WKNavigation, WKNavigationAction, WKNavigationActionPolicy, WKNavigationDelegate, WKNavigationResponse, WKNavigationResponsePolicy, @@ -37,6 +41,9 @@ pub struct WryNavigationDelegateIvars { pub download_delegate: Option>, pub on_page_load_handler: Option>, pub on_web_content_process_terminate_handler: Option>, + pub client_certificate_p12: Option>, + pub client_certificate_password: Option, + pub trusted_ca_certificate: Option>, } define_class!( @@ -102,6 +109,21 @@ define_class!( fn web_content_process_did_terminate(&self, webview: &WKWebView) { web_content_process_did_terminate(self, webview); } + + #[cfg(target_os = "macos")] + #[unsafe(method(webView:didReceiveAuthenticationChallenge:completionHandler:))] + fn did_receive_authentication_challenge( + &self, + _webview: &WKWebView, + challenge: &NSURLAuthenticationChallenge, + handler: &block2::Block< + dyn Fn(NSURLSessionAuthChallengeDisposition, *mut NSURLCredential), + >, + ) { + crate::wkwebview::navigation_auth::did_receive_authentication_challenge( + self, challenge, handler, + ); + } } ); @@ -115,6 +137,9 @@ impl WryNavigationDelegate { download_delegate: Option>, on_page_load_handler: Option>, on_web_content_process_terminate_handler: Option>, + client_certificate_p12: Option>, + client_certificate_password: Option, + trusted_ca_certificate: Option>, mtm: MainThreadMarker, ) -> Retained { let navigation_policy_function = Box::new(move |url: String| -> bool { @@ -151,6 +176,9 @@ impl WryNavigationDelegate { download_delegate, on_page_load_handler, on_web_content_process_terminate_handler, + client_certificate_p12, + client_certificate_password, + trusted_ca_certificate, }); unsafe { msg_send![super(delegate), init] } diff --git a/src/wkwebview/mod.rs b/src/wkwebview/mod.rs index 685634980..2239b3d81 100644 --- a/src/wkwebview/mod.rs +++ b/src/wkwebview/mod.rs @@ -6,6 +6,7 @@ mod download; #[cfg(target_os = "macos")] mod drag_drop; mod navigation; +mod navigation_auth; #[cfg(feature = "mac-proxy")] mod proxy; #[cfg(target_os = "macos")] @@ -590,6 +591,9 @@ impl InnerWebView { download_delegate.clone(), attributes.on_page_load_handler, pl_attrs.on_web_content_process_terminate_handler, + attributes.client_certificate_p12, + attributes.client_certificate_password, + attributes.trusted_ca_certificate, mtm, ); diff --git a/src/wkwebview/navigation_auth.rs b/src/wkwebview/navigation_auth.rs new file mode 100644 index 000000000..86d771115 --- /dev/null +++ b/src/wkwebview/navigation_auth.rs @@ -0,0 +1,181 @@ +// Copyright 2020-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Authentication challenge handling for mTLS (mutual TLS) connections. + +use objc2::runtime::AnyObject; +use objc2::msg_send; +use objc2::rc::Retained; +use objc2::DeclaredClass; +use objc2_foundation::{ + NSData, NSString, + NSURLAuthenticationChallenge, NSURLCredential, + NSURLSessionAuthChallengeDisposition, +}; + +use super::class::wry_navigation_delegate::WryNavigationDelegate; + +#[link(name = "Security", kind = "framework")] +extern "C" { + fn SecCertificateCreateWithData( + allocator: *const std::ffi::c_void, + data: *const AnyObject, + ) -> *mut std::ffi::c_void; + fn SecTrustSetAnchorCertificates( + trust: *const std::ffi::c_void, + anchors: *const AnyObject, + ) -> i32; + fn SecTrustSetAnchorCertificatesOnly( + trust: *const std::ffi::c_void, + only: bool, + ) -> i32; + fn SecTrustEvaluateWithError( + trust: *const std::ffi::c_void, + error: *mut *mut std::ffi::c_void, + ) -> bool; + fn SecPKCS12Import( + pkcs12: *const AnyObject, + options: *const AnyObject, + items: *mut *mut AnyObject, + ) -> i32; + fn CFRelease(cf: *const std::ffi::c_void); +} + +pub(crate) fn did_receive_authentication_challenge( + delegate: &WryNavigationDelegate, + challenge: &NSURLAuthenticationChallenge, + handler: &block2::Block< + dyn Fn(NSURLSessionAuthChallengeDisposition, *mut NSURLCredential), + >, +) { + unsafe { + let protection_space = challenge.protectionSpace(); + let auth_method = protection_space.authenticationMethod(); + + let server_trust_method = NSString::from_str("NSURLAuthenticationMethodServerTrust"); + let client_cert_method = NSString::from_str("NSURLAuthenticationMethodClientCertificate"); + + // Server trust challenge: pin CA cert if provided + if auth_method.isEqualToString(&server_trust_method) { + if let Some(ref ca_der) = delegate.ivars().trusted_ca_certificate { + let ns_data = NSData::with_bytes(ca_der); + let ca_cert = SecCertificateCreateWithData( + std::ptr::null(), + Retained::as_ptr(&ns_data) as *const AnyObject, + ); + + if !ca_cert.is_null() { + let server_trust: *const std::ffi::c_void = + msg_send![&*protection_space, serverTrust]; + if !server_trust.is_null() { + let cert_obj = ca_cert as *mut AnyObject; + let array: Retained = msg_send![ + objc2::runtime::AnyClass::get(c"NSArray").unwrap(), + arrayWithObject: cert_obj + ]; + SecTrustSetAnchorCertificates( + server_trust, + Retained::as_ptr(&array) as *const AnyObject, + ); + SecTrustSetAnchorCertificatesOnly(server_trust, true); + + let mut error: *mut std::ffi::c_void = std::ptr::null_mut(); + let trusted = SecTrustEvaluateWithError(server_trust, &mut error); + CFRelease(ca_cert); // Release the SecCertificateRef + + if trusted { + let credential: *mut NSURLCredential = msg_send![ + objc2::runtime::AnyClass::get(c"NSURLCredential").unwrap(), + credentialForTrust: server_trust + ]; + handler.call(( + NSURLSessionAuthChallengeDisposition::UseCredential, + credential, + )); + return; + } + // Trust evaluation failed with pinned CA; cancel the challenge + // rather than falling through to accept an untrusted server. + handler.call(( + NSURLSessionAuthChallengeDisposition::CancelAuthenticationChallenge, + std::ptr::null_mut(), + )); + return; + } + CFRelease(ca_cert); + } + } + + // No custom CA configured: use default system trust evaluation. + // This preserves the standard WKWebView behavior of rejecting + // untrusted/self-signed certificates. + handler.call(( + NSURLSessionAuthChallengeDisposition::PerformDefaultHandling, + std::ptr::null_mut(), + )); + return; + } + + // Client certificate challenge: extract identity from PKCS#12 data + if auth_method.isEqualToString(&client_cert_method) { + if let Some(ref p12_data) = delegate.ivars().client_certificate_p12 { + let password = delegate + .ivars() + .client_certificate_password + .as_deref() + .unwrap_or(""); + let ns_data = NSData::with_bytes(p12_data); + let ns_password = NSString::from_str(password); + + // kSecImportExportPassphrase = "passphrase" + let passphrase_key = NSString::from_str("passphrase"); + let options: Retained = msg_send![ + objc2::runtime::AnyClass::get(c"NSDictionary").unwrap(), + dictionaryWithObject: &*ns_password, + forKey: &*passphrase_key + ]; + + let mut items: *mut AnyObject = std::ptr::null_mut(); + let status = SecPKCS12Import( + Retained::as_ptr(&ns_data) as *const AnyObject, + Retained::as_ptr(&options) as *const AnyObject, + &mut items, + ); + + if status == 0 && !items.is_null() { + let count: usize = msg_send![items, count]; + if count > 0 { + let first: *mut AnyObject = msg_send![items, objectAtIndex: 0usize]; + // kSecImportItemIdentity = "identity" + let identity_key = NSString::from_str("identity"); + let identity: *mut std::ffi::c_void = + msg_send![first, objectForKey: &*identity_key]; + + if !identity.is_null() { + let credential: *mut NSURLCredential = msg_send![ + objc2::runtime::AnyClass::get(c"NSURLCredential").unwrap(), + credentialWithIdentity: identity, + certificates: std::ptr::null::(), + persistence: 0isize // NSURLCredentialPersistenceNone + ]; + CFRelease(items as *const std::ffi::c_void); + handler.call(( + NSURLSessionAuthChallengeDisposition::UseCredential, + credential, + )); + return; + } + } + CFRelease(items as *const std::ffi::c_void); + } + } + } + + // Default handling for all other challenges + handler.call(( + NSURLSessionAuthChallengeDisposition::PerformDefaultHandling, + std::ptr::null_mut(), + )); + } +}