diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f11db676..6d2095f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ to docs, or any other relevant information. ## [Unreleased] ### Added +* Support for dynamic client certificate resolution via `TlsOptions::client_cert_resolver`, which + accepts an `Arc` for per-handshake mTLS certificate selection. This enables + transparent certificate rotation without process restarts — useful for short-lived certificates + managed by Vault, cert-manager, or HSM-backed signers. `ResolvesClientCert`, `CertifiedKey`, and + `SignatureScheme` are re-exported from the crate root for convenience. * `client()` and `workflow_handle()` helpers to `ActivityContext` for easily obtaining a Temporal client * Exposed `backoff_start_interval` when continuing as new, which will delay the first task of the continued workflow by the configured interval. diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 87c8c7406..b35eadf43 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -46,6 +46,7 @@ tokio = { version = "1.47", default-features = false, features = [ ] } tonic = { workspace = true, default-features = false, features = ["tls-native-roots", "channel", "gzip"] } tokio-rustls = { version = "0.26", default-features = false } +rustls-native-certs = "0.8" tower = { version = "0.5", features = ["util"] } tracing = "0.1" url = "2.5" diff --git a/crates/client/src/dns.rs b/crates/client/src/dns.rs index 1e3d63378..7ece1fa61 100644 --- a/crates/client/src/dns.rs +++ b/crates/client/src/dns.rs @@ -89,7 +89,18 @@ async fn build_endpoint( patched } }); - let channel = add_tls_to_channel(tls_for_ip.as_ref().or(tls_options), channel).await?; + let tls_result = add_tls_to_channel(tls_for_ip.as_ref().or(tls_options), channel).await?; + + let channel = match tls_result { + crate::TlsConfigResult::Standard(ep) => ep, + crate::TlsConfigResult::CustomConnector { .. } => { + return Err(ClientConnectError::InvalidConfig( + "client_cert_resolver is not yet supported with dns_load_balancing. \ + Disable dns_load_balancing or use static client_tls_options instead." + .to_owned(), + )); + } + }; let channel = if let Some(keep_alive) = keep_alive { channel diff --git a/crates/client/src/envconfig.rs b/crates/client/src/envconfig.rs index 1b3a3fc50..f1f1110d3 100644 --- a/crates/client/src/envconfig.rs +++ b/crates/client/src/envconfig.rs @@ -90,6 +90,7 @@ fn build_tls_options(tls: ClientConfigTLS) -> Result { domain: tls.server_name, client_tls_options, server_cert_verifier: None, + client_cert_resolver: None, }) } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 0fb02d22d..a682dfd9b 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -47,6 +47,21 @@ pub mod danger { /// while explicitly acknowledging the danger in the import path. pub use tokio_rustls::rustls::client::danger::ServerCertVerifier; } +/// Re-export of [`tokio_rustls::rustls::SignatureScheme`] — parameter type +/// of [`ResolvesClientCert::resolve`]. +pub use tokio_rustls::rustls::SignatureScheme; +/// Re-export the `ResolvesClientCert` trait and supporting types so that users +/// can implement dynamic client certificate resolution without depending on +/// `tokio-rustls` directly. +/// +/// This enables transparent certificate rotation for mTLS connections (e.g., +/// short-lived certs issued by Vault and rotated on disk by a sidecar). +/// +/// Implementors will also need [`CertifiedKey`] and [`SignatureScheme`]. +pub use tokio_rustls::rustls::client::ResolvesClientCert; +/// Re-export of [`tokio_rustls::rustls::sign::CertifiedKey`] — the return type +/// of [`ResolvesClientCert::resolve`]. +pub use tokio_rustls::rustls::sign::CertifiedKey; pub use tonic; pub use workflow_handle::{ UntypedQuery, UntypedSignal, UntypedUpdate, UntypedWorkflow, UntypedWorkflowHandle, @@ -200,8 +215,18 @@ impl Connection { Some(handle), ) } else { - let channel = Endpoint::from_shared(options.target.to_string())?; - let channel = add_tls_to_channel(options.tls_options.as_ref(), channel).await?; + let endpoint = Endpoint::from_shared(options.target.to_string())?; + let tls_result = add_tls_to_channel(options.tls_options.as_ref(), endpoint).await?; + + let (channel, custom_connector_info) = match tls_result { + TlsConfigResult::Standard(ep) => (ep, None), + TlsConfigResult::CustomConnector { + endpoint: ep, + rustls_config, + domain, + } => (ep, Some((rustls_config, domain))), + }; + let channel = if let Some(keep_alive) = options.keep_alive.as_ref() { channel .keep_alive_while_idle(true) @@ -215,9 +240,31 @@ impl Connection { } else { channel }; - // If there is a proxy, we have to connect that way + // Validate that proxy and dynamic cert resolver aren't combined + if options.http_connect_proxy.is_some() && custom_connector_info.is_some() { + return Err(ClientConnectError::InvalidConfig( + "client_cert_resolver is not yet supported with http_connect_proxy. \ + Use static client_tls_options when using a proxy, or remove the proxy." + .to_owned(), + )); + } + // Connect, using a custom TLS connector if dynamic cert resolution is needed let channel = if let Some(proxy) = options.http_connect_proxy.as_ref() { proxy.connect_endpoint(&channel).await? + } else if let Some((rustls_config, domain)) = custom_connector_info { + let server_name = + tokio_rustls::rustls::pki_types::ServerName::try_from(domain.as_str()) + .map_err(|e| { + ClientConnectError::InvalidConfig(format!( + "Invalid TLS domain name '{domain}': {e}" + )) + })? + .to_owned(); + let connector = DynamicTlsConnector { + tls: tokio_rustls::TlsConnector::from(rustls_config), + domain: Arc::new(server_name), + }; + channel.connect_with_connector(connector).await? } else { channel.connect().await? }; @@ -416,12 +463,32 @@ impl ClientHeaders { } } +/// Result of TLS configuration: either standard tonic TLS was applied to the endpoint, +/// or a custom rustls config is needed for dynamic certificate resolution. +#[derive(Debug)] +enum TlsConfigResult { + /// Standard tonic TLS was applied, endpoint is ready to connect normally. + Standard(Endpoint), + /// A custom rustls::ClientConfig is needed. The endpoint has no TLS configured; + /// the caller must use `connect_with_connector` with a custom TLS connector. + CustomConnector { + endpoint: Endpoint, + rustls_config: Arc, + domain: String, + }, +} + /// If TLS is configured, set the appropriate options on the provided channel and return it. /// Passes it through if TLS options not set. +/// +/// When `client_cert_resolver` is set, tonic's built-in TLS cannot be used (it only supports +/// static client certificates). In that case, we return `TlsConfigResult::CustomConnector` +/// with a manually-built `rustls::ClientConfig` that the caller must use with +/// `connect_with_connector`. async fn add_tls_to_channel( tls_options: Option<&TlsOptions>, mut channel: Endpoint, -) -> Result { +) -> Result { if let Some(tls_cfg) = tls_options { if tls_cfg.server_cert_verifier.is_some() && tls_cfg.server_root_ca_cert.is_some() { return Err(ClientConnectError::InvalidConfig( @@ -429,6 +496,42 @@ async fn add_tls_to_channel( )); } + if tls_cfg.client_tls_options.is_some() && tls_cfg.client_cert_resolver.is_some() { + return Err(ClientConnectError::InvalidConfig( + "Cannot set both `client_tls_options` and `client_cert_resolver`. \ + Use `client_tls_options` for static certificates or \ + `client_cert_resolver` for dynamic certificate resolution, but not both." + .to_owned(), + )); + } + + // Extract the domain for SNI / :authority header + let domain_override = tls_cfg.domain.clone(); + if let Some(domain) = &domain_override { + let uri: Uri = format!("https://{domain}").parse()?; + channel = channel.origin(uri); + } + + // Dynamic certificate resolver path: build rustls::ClientConfig manually + if let Some(resolver) = &tls_cfg.client_cert_resolver { + let rustls_config = build_custom_rustls_config(tls_cfg, Some(resolver.clone()))?; + let sni_domain = domain_override + .or_else(|| channel.uri().host().map(str::to_owned)) + .ok_or_else(|| { + ClientConnectError::InvalidConfig( + "Cannot determine TLS server name for dynamic cert resolution: \ + set 'domain' in TlsOptions or use a URL with a hostname" + .to_owned(), + ) + })?; + return Ok(TlsConfigResult::CustomConnector { + endpoint: channel, + rustls_config: Arc::new(rustls_config), + domain: sni_domain, + }); + } + + // Standard tonic TLS path let mut tls = tonic::transport::ClientTlsConfig::new(); if tls_cfg.server_cert_verifier.is_none() { @@ -442,13 +545,6 @@ async fn add_tls_to_channel( if let Some(domain) = &tls_cfg.domain { tls = tls.domain_name(domain); - - // This song and dance ultimately is just to make sure the `:authority` header ends - // up correct on requests while we use TLS. Setting the header directly in our - // interceptor doesn't work since seemingly it is overridden at some point by - // something lower level. - let uri: Uri = format!("https://{domain}").parse()?; - channel = channel.origin(uri); } if let Some(client_opts) = &tls_cfg.client_tls_options { @@ -457,15 +553,194 @@ async fn add_tls_to_channel( tls = tls.identity(client_identity); } - return if let Some(verifier) = &tls_cfg.server_cert_verifier { + let endpoint = if let Some(verifier) = &tls_cfg.server_cert_verifier { channel .tls_config_with_verifier(tls, verifier.clone()) - .map_err(Into::into) + .map_err(ClientConnectError::from)? } else { - channel.tls_config(tls).map_err(Into::into) + channel.tls_config(tls).map_err(ClientConnectError::from)? }; + return Ok(TlsConfigResult::Standard(endpoint)); + } + Ok(TlsConfigResult::Standard(channel)) +} + +/// Build a `rustls::ClientConfig` manually for the dynamic certificate resolver path. +/// +/// This replicates the logic that tonic normally handles internally but uses +/// `with_client_cert_resolver` instead of `with_client_auth_cert`. +fn build_custom_rustls_config( + tls_cfg: &TlsOptions, + client_cert_resolver: Option>, +) -> Result { + use tokio_rustls::rustls::{ClientConfig, RootCertStore, crypto}; + + // Get or install a crypto provider + let provider = crypto::CryptoProvider::get_default() + .cloned() + .or_else(|| { + // Try ring first, then aws-lc, matching tonic's behavior + #[cfg(feature = "tls-ring")] + { + return Some(Arc::new(crypto::ring::default_provider())); + } + #[cfg(feature = "tls-aws-lc")] + { + return Some(Arc::new(crypto::aws_lc_rs::default_provider())); + } + #[allow(unreachable_code)] + None + }) + .ok_or_else(|| { + ClientConnectError::InvalidConfig( + "No TLS crypto provider available. Enable the `tls-ring` or `tls-aws-lc` feature." + .to_owned(), + ) + })?; + + let builder = ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| { + ClientConnectError::InvalidConfig(format!("Failed to configure TLS protocols: {e}")) + })?; + + // Configure server certificate verification + let builder = if let Some(verifier) = &tls_cfg.server_cert_verifier { + builder + .dangerous() + .with_custom_certificate_verifier(verifier.clone()) + } else { + use std::io::Cursor; + use tokio_rustls::rustls::pki_types::{CertificateDer, pem::PemObject as _}; + + let mut roots = RootCertStore::empty(); + if let Some(ca_cert) = &tls_cfg.server_root_ca_cert { + let certs: Vec> = + CertificateDer::pem_reader_iter(&mut Cursor::new(ca_cert)) + .collect::, _>>() + .map_err(|e| { + ClientConnectError::InvalidConfig(format!( + "Failed to parse CA certificate PEM: {e}" + )) + })?; + roots.add_parsable_certificates(certs); + if roots.is_empty() { + return Err(ClientConnectError::InvalidConfig( + "None of the provided CA certificates could be parsed. \ + Ensure the PEM data contains valid X.509 certificates." + .to_owned(), + )); + } + } else { + // Use native OS root certificates (same logic as tonic's with_native_roots) + let native_result = rustls_native_certs::load_native_certs(); + if !native_result.errors.is_empty() { + warn!( + "errors occurred when loading native certs: {:?}", + native_result.errors + ); + } + if native_result.certs.is_empty() { + return Err(ClientConnectError::InvalidConfig( + "No native TLS root certificates found".to_owned(), + )); + } + roots.add_parsable_certificates(native_result.certs); + if roots.is_empty() { + return Err(ClientConnectError::InvalidConfig( + "Native TLS root certificates were found but none could be parsed".to_owned(), + )); + } + } + builder.with_root_certificates(roots) + }; + + // Configure client authentication + let mut config = if let Some(resolver) = client_cert_resolver { + builder.with_client_cert_resolver(resolver) + } else { + builder.with_no_client_auth() + }; + + // Set ALPN to h2 for HTTP/2 (required by gRPC) + config.alpn_protocols.push(b"h2".to_vec()); + + Ok(config) +} + +/// Default TCP connect timeout for the dynamic TLS connector. +/// Matches a reasonable timeout for production use; the built-in tonic connector +/// uses `Endpoint::connect_timeout()` which we cannot access from a custom connector. +const DYNAMIC_TLS_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); + +/// A custom connector that wraps a TCP connector with TLS using a custom +/// `rustls::ClientConfig` (needed for dynamic cert resolution). +#[derive(Clone)] +struct DynamicTlsConnector { + tls: tokio_rustls::TlsConnector, + domain: Arc>, +} + +impl std::fmt::Debug for DynamicTlsConnector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DynamicTlsConnector") + .field("domain", &self.domain) + .finish() + } +} + +impl tower::Service for DynamicTlsConnector { + type Response = hyper_util::rt::TokioIo>; + type Error = Box; + type Future = + Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, uri: Uri) -> Self::Future { + let tls = self.tls.clone(); + let domain = self.domain.clone(); + + Box::pin(async move { + let host = uri + .host() + .ok_or_else(|| -> Box { + format!("URI has no host for TLS connection: {uri}").into() + })?; + let port = uri.port_u16().unwrap_or(443); + let addr = format!("{host}:{port}"); + + debug!(target: "temporal_client", %uri, %addr, "DynamicTlsConnector: establishing TCP+TLS connection"); + + // Use a timeout to prevent hanging on unreachable hosts. + // Tonic's built-in connector respects Endpoint::connect_timeout(), + // but custom connectors must handle timeouts themselves. + let tcp = tokio::time::timeout( + DYNAMIC_TLS_CONNECT_TIMEOUT, + tokio::net::TcpStream::connect(&addr), + ) + .await + .map_err(|_| -> Box { + format!( + "TCP connect to {addr} timed out after {}s", + DYNAMIC_TLS_CONNECT_TIMEOUT.as_secs() + ) + .into() + })? + .map_err(|e| -> Box { + format!("TCP connect to {addr} failed: {e}").into() + })?; + + // Disable Nagle's algorithm for low-latency gRPC messaging + tcp.set_nodelay(true)?; + + let tls_stream = tls.connect(domain.as_ref().to_owned(), tcp).await?; + debug!(target: "temporal_client", %addr, "DynamicTlsConnector: TLS handshake complete"); + Ok(hyper_util::rt::TokioIo::new(tls_stream)) + }) } - Ok(channel) } fn parse_ascii_headers( @@ -1581,7 +1856,7 @@ mod tests { let endpoint = tonic::transport::Channel::from_static("https://test.temporal.io:7233"); let result = add_tls_to_channel(Some(&tls_opts), endpoint).await; assert!( - result.is_ok(), + matches!(&result, Ok(TlsConfigResult::Standard(_))), "add_tls_to_channel should succeed with a custom verifier: {:?}", result.err() ); @@ -1616,11 +1891,275 @@ mod tests { let endpoint = tonic::transport::Channel::from_static("https://test.temporal.io:7233"); let result = add_tls_to_channel(Some(&tls_opts), endpoint).await; assert!( - result.is_ok(), + matches!(&result, Ok(TlsConfigResult::Standard(_))), "add_tls_to_channel should succeed without a verifier (native roots): {:?}", result.err() ); } + + // --- Dynamic client cert resolver tests --- + + /// A mock `ResolvesClientCert` that always returns None (no client cert). + /// Used to test the plumbing without requiring real certificates. + #[derive(Debug)] + struct MockClientCertResolver; + + impl tokio_rustls::rustls::client::ResolvesClientCert for MockClientCertResolver { + fn resolve( + &self, + _acceptable_issuers: &[&[u8]], + _sigschemes: &[tokio_rustls::rustls::SignatureScheme], + ) -> Option> { + None // No client cert available — server may reject, but plumbing works + } + + fn has_certs(&self) -> bool { + false + } + } + + #[tokio::test] + async fn add_tls_with_client_cert_resolver_returns_custom_connector() { + let resolver = Arc::new(MockClientCertResolver); + let tls_opts = TlsOptions { + client_cert_resolver: Some(resolver), + domain: Some("test.temporal.io".to_string()), + ..Default::default() + }; + let endpoint = tonic::transport::Channel::from_static("https://test.temporal.io:7233"); + let result = add_tls_to_channel(Some(&tls_opts), endpoint).await; + match result { + Ok(TlsConfigResult::CustomConnector { + domain, + rustls_config, + .. + }) => { + assert_eq!(domain, "test.temporal.io"); + // Verify ALPN is set to h2 + assert_eq!(rustls_config.alpn_protocols, vec![b"h2".to_vec()]); + } + other => panic!( + "Expected TlsConfigResult::CustomConnector, got {:?}", + other.err() + ), + } + } + + #[tokio::test] + async fn add_tls_with_client_cert_resolver_inherits_domain_from_endpoint() { + let resolver = Arc::new(MockClientCertResolver); + let tls_opts = TlsOptions { + client_cert_resolver: Some(resolver), + // No explicit domain — should be derived from the endpoint URI + ..Default::default() + }; + let endpoint = + tonic::transport::Channel::from_static("https://my-server.example.com:7233"); + let result = add_tls_to_channel(Some(&tls_opts), endpoint).await; + match result { + Ok(TlsConfigResult::CustomConnector { domain, .. }) => { + assert_eq!(domain, "my-server.example.com"); + } + other => panic!( + "Expected TlsConfigResult::CustomConnector, got {:?}", + other.err() + ), + } + } + + #[tokio::test] + async fn add_tls_with_resolver_and_custom_verifier() { + let resolver = Arc::new(MockClientCertResolver); + let tls_opts = TlsOptions { + client_cert_resolver: Some(resolver), + server_cert_verifier: Some(Arc::new(MockVerifier)), + domain: Some("test.temporal.io".to_string()), + ..Default::default() + }; + let endpoint = tonic::transport::Channel::from_static("https://test.temporal.io:7233"); + let result = add_tls_to_channel(Some(&tls_opts), endpoint).await; + assert!( + matches!(&result, Ok(TlsConfigResult::CustomConnector { .. })), + "Should succeed when combining cert resolver with custom server verifier: {:?}", + result.err() + ); + } + + #[tokio::test] + async fn add_tls_with_resolver_and_custom_ca_cert() { + // Use a valid PEM-formatted CA certificate + let ca_pem = include_bytes!("../tests/testdata/ca.pem"); + let resolver = Arc::new(MockClientCertResolver); + let tls_opts = TlsOptions { + client_cert_resolver: Some(resolver), + server_root_ca_cert: Some(ca_pem.to_vec()), + domain: Some("test.temporal.io".to_string()), + ..Default::default() + }; + let endpoint = tonic::transport::Channel::from_static("https://test.temporal.io:7233"); + let result = add_tls_to_channel(Some(&tls_opts), endpoint).await; + assert!( + matches!(&result, Ok(TlsConfigResult::CustomConnector { .. })), + "Should succeed when combining cert resolver with custom CA cert: {:?}", + result.err() + ); + } + + #[tokio::test] + async fn add_tls_both_static_and_dynamic_client_cert_fails() { + let resolver = Arc::new(MockClientCertResolver); + let tls_opts = TlsOptions { + client_tls_options: Some(ClientTlsOptions { + client_cert: b"some-cert".to_vec(), + client_private_key: b"some-key".to_vec(), + }), + client_cert_resolver: Some(resolver), + domain: Some("test.temporal.io".to_string()), + ..Default::default() + }; + let endpoint = tonic::transport::Channel::from_static("https://test.temporal.io:7233"); + let result = add_tls_to_channel(Some(&tls_opts), endpoint).await; + assert!( + matches!(result, Err(ClientConnectError::InvalidConfig(msg)) if msg.contains("client_tls_options") && msg.contains("client_cert_resolver")), + "Should fail with InvalidConfig when both static and dynamic client certs are set" + ); + } + + #[tokio::test] + async fn add_tls_no_options_returns_standard_passthrough() { + let endpoint = tonic::transport::Channel::from_static("http://localhost:7233"); + let result = add_tls_to_channel(None, endpoint).await; + assert!( + matches!(&result, Ok(TlsConfigResult::Standard(_))), + "Should return Standard when no TLS options are set" + ); + } + + #[test] + fn build_custom_rustls_config_with_resolver() { + let resolver = Arc::new(MockClientCertResolver); + let tls_opts = TlsOptions { + domain: Some("test.temporal.io".to_string()), + ..Default::default() + }; + let config = build_custom_rustls_config(&tls_opts, Some(resolver)); + assert!(config.is_ok(), "Should build config: {:?}", config.err()); + let config = config.unwrap(); + assert_eq!(config.alpn_protocols, vec![b"h2".to_vec()]); + } + + #[test] + fn build_custom_rustls_config_without_resolver() { + let tls_opts = TlsOptions { + domain: Some("test.temporal.io".to_string()), + ..Default::default() + }; + let config = build_custom_rustls_config(&tls_opts, None); + assert!(config.is_ok(), "Should build config: {:?}", config.err()); + } + + #[test] + fn build_custom_rustls_config_with_custom_verifier_and_resolver() { + let resolver = Arc::new(MockClientCertResolver); + let tls_opts = TlsOptions { + server_cert_verifier: Some(Arc::new(MockVerifier)), + domain: Some("test.temporal.io".to_string()), + ..Default::default() + }; + let config = build_custom_rustls_config(&tls_opts, Some(resolver)); + assert!( + config.is_ok(), + "Should build config with custom verifier + resolver: {:?}", + config.err() + ); + } + + #[test] + fn tls_options_debug_shows_custom_for_resolver() { + let resolver = Arc::new(MockClientCertResolver); + let tls_opts = TlsOptions { + client_cert_resolver: Some(resolver), + ..Default::default() + }; + let debug_str = format!("{:?}", tls_opts); + assert!( + debug_str.contains("\"\""), + "Debug should show for client_cert_resolver: {debug_str}" + ); + assert!( + debug_str.contains("client_cert_resolver"), + "Debug should contain field name: {debug_str}" + ); + } + + #[test] + fn tls_options_default_has_no_resolver() { + let tls_opts = TlsOptions::default(); + assert!(tls_opts.client_cert_resolver.is_none()); + assert!(tls_opts.client_tls_options.is_none()); + assert!(tls_opts.server_cert_verifier.is_none()); + } + + #[test] + fn dynamic_tls_connector_is_clone_and_debug() { + // Verify DynamicTlsConnector is Clone (required by tonic) and Debug + let config = build_custom_rustls_config( + &TlsOptions::default(), + Some(Arc::new(MockClientCertResolver)), + ) + .unwrap(); + let server_name = + tokio_rustls::rustls::pki_types::ServerName::try_from("test.temporal.io") + .unwrap() + .to_owned(); + let connector = DynamicTlsConnector { + tls: tokio_rustls::TlsConnector::from(Arc::new(config)), + domain: Arc::new(server_name), + }; + let _cloned = connector.clone(); + let debug_str = format!("{:?}", connector); + assert!( + debug_str.contains("DynamicTlsConnector"), + "Debug should show struct name: {debug_str}" + ); + assert!( + debug_str.contains("test.temporal.io"), + "Debug should show domain: {debug_str}" + ); + } + + #[tokio::test] + async fn add_tls_resolver_with_ip_host_uses_ip_as_domain() { + // When no explicit domain is set, the host from the URI is used for SNI. + // This verifies the .or_else() fallback works correctly. + let resolver = Arc::new(MockClientCertResolver); + let tls_opts = TlsOptions { + client_cert_resolver: Some(resolver), + // No domain set — should fall back to URI host + ..Default::default() + }; + let endpoint = tonic::transport::Channel::from_static("https://192.168.1.100:7233"); + let result = add_tls_to_channel(Some(&tls_opts), endpoint).await; + match result { + Ok(TlsConfigResult::CustomConnector { domain, .. }) => { + assert_eq!(domain, "192.168.1.100"); + } + other => panic!( + "Expected CustomConnector with IP domain, got {:?}", + other.err() + ), + } + } + + #[test] + fn re_exports_are_accessible() { + // Verify that CertifiedKey and SignatureScheme are re-exported + // and usable from the crate root. This is a compile-time check. + fn _assert_types_exist(_resolver: &dyn ResolvesClientCert, _scheme: SignatureScheme) { + // This function just needs to compile + } + let _ = SignatureScheme::ECDSA_NISTP256_SHA256; + } } mod list_workflows_tests { diff --git a/crates/client/src/options_structs.rs b/crates/client/src/options_structs.rs index ed9fb97e1..d992cd838 100644 --- a/crates/client/src/options_structs.rs +++ b/crates/client/src/options_structs.rs @@ -17,7 +17,7 @@ use temporalio_common::{ }, telemetry::metrics::TemporalMeter, }; -use tokio_rustls::rustls::client::danger::ServerCertVerifier; +use tokio_rustls::rustls::client::{ResolvesClientCert, danger::ServerCertVerifier}; use url::Url; /// Options for [crate::Connection::connect]. @@ -161,6 +161,9 @@ pub struct TlsOptions { /// the domain name will be extracted from the URL used to connect. pub domain: Option, /// TLS info for the client. If specified, core will attempt to use mTLS. + /// + /// Mutually exclusive with [`client_cert_resolver`](TlsOptions::client_cert_resolver). + /// Setting both is an error. pub client_tls_options: Option, /// Optional custom server certificate verifier. When set, this replaces the default /// certificate verification and `server_root_ca_cert` is ignored. @@ -179,6 +182,25 @@ pub struct TlsOptions { /// Note that `domain` is still respected for the `:authority` header / origin override /// even when a custom verifier is set. pub server_cert_verifier: Option>, + /// Optional dynamic client certificate resolver. When set, the resolver is called during + /// each TLS handshake to provide the client certificate, enabling transparent certificate + /// rotation without process restart. + /// + /// This is useful for: + /// - Short-lived mTLS certificates rotated on disk by a sidecar (e.g., Vault agent) + /// - HSM-backed certificate selection + /// - Dynamic certificate selection based on server hints + /// + /// Mutually exclusive with [`client_tls_options`](TlsOptions::client_tls_options). + /// Setting both is an error. + /// + /// The resolver must implement [`ResolvesClientCert`] from the `rustls` crate. + /// A simple implementation that reloads certificates from disk can use + /// `Arc>` internally. + /// + /// **Note:** The resolver is called on each new TLS handshake (new connections), not on every + /// RPC over an existing HTTP/2 connection. Certificate rotation takes effect upon reconnection. + pub client_cert_resolver: Option>, } impl std::fmt::Debug for TlsOptions { @@ -197,6 +219,10 @@ impl std::fmt::Debug for TlsOptions { "server_cert_verifier", &self.server_cert_verifier.as_ref().map(|_| ""), ) + .field( + "client_cert_resolver", + &self.client_cert_resolver.as_ref().map(|_| ""), + ) .finish() } } diff --git a/crates/client/tests/testdata/ca.pem b/crates/client/tests/testdata/ca.pem new file mode 100644 index 000000000..22e96e46c --- /dev/null +++ b/crates/client/tests/testdata/ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBTCCAe2gAwIBAgIUIm49tk/Q/nRvNbIVGtJ5in2HTNcwDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHVGVzdCBDQTAeFw0yNjA2MjEwMzM5NDVaFw0zNjA2MTgw +MzM5NDVaMBIxEDAOBgNVBAMMB1Rlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQCnzLDoBoOxyddipk9WibF6TYC5KEk9pzbM1JUt/SR2HWljx9Cn +EHRHgn3uGdG9RJmFskIgTngsG77YnyBR8HhDG1sCyclcxwwwyyno+pyXe8AEhtMx +kj10rTJ5dNe34Gs2t7X0JBLy5yxrpKs9JuC4y4FCjtmlX3S4cwPJWdJHGmHv5Lho +7jq/RjcQV4XkK8bzhiQwrIqvffoOEzxXEHMbaspNsf4HxQ8EuHSbAiAFCbZotAxe +iUkYe6STOWdRufxbI14JwADFn2IW8fMe85EDK3Y2XmD+K2TM6AGOyqVQXbu+WZcL +LiR0h/s5zkoxi55/iGh091W0dY92djcDrR8pAgMBAAGjUzBRMB0GA1UdDgQWBBSq +4PARkMyOIQSNEijzfDUkNnrcLTAfBgNVHSMEGDAWgBSq4PARkMyOIQSNEijzfDUk +NnrcLTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB4zEBxWa4H +l4kxxfj++f3iuV1weNEQMDBErp7Bp1utFJDNTPrqBPYvvq5n9X8XdrRt474aKZYl +RcLq1Z358ypClQJyn8+jvJ/CuJStw+zUCwd6jvbJ+TBFmD5HBcuG7VQBq8LvCiyg +QULVWbiervzSdNtic7g4j055DgPq1JNWhlxUzTcVQDxfys0rfhMyfS0P0E3XjP13 +FWIV7s/9yz4D6kwMykaXjaKaiRU8jpUDwz2k8youznsJpL+AqldtPgDRqAxc/djn +jWYTAMCcO4G0cDnvQ3zl+1PNpjv1PL2xvmSIeeH85ioBLfMpts65B6Xhpn/FM30p +Xqo1H4wpnJcI +-----END CERTIFICATE----- diff --git a/crates/sdk-core-c-bridge/src/client.rs b/crates/sdk-core-c-bridge/src/client.rs index cf93d1a8d..ae0656a4e 100644 --- a/crates/sdk-core-c-bridge/src/client.rs +++ b/crates/sdk-core-c-bridge/src/client.rs @@ -1484,6 +1484,7 @@ impl TryFrom<&ClientTlsOptions> for temporalio_client::TlsOptions { } }, server_cert_verifier: None, + client_cert_resolver: None, }) } } diff --git a/crates/sdk-core/tests/common/mod.rs b/crates/sdk-core/tests/common/mod.rs index a7e30ba0f..cd8d48a8e 100644 --- a/crates/sdk-core/tests/common/mod.rs +++ b/crates/sdk-core/tests/common/mod.rs @@ -854,6 +854,7 @@ pub(crate) fn get_integ_tls_config() -> Option { client_private_key, }), server_cert_verifier: None, + client_cert_resolver: None, }) } else { None