diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 067bcfa1c..bf460cec9 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -45,6 +45,7 @@ tokio = { version = "1.47", default-features = false, features = [ "time", ] } tonic = { workspace = true, default-features = false, features = ["tls-native-roots", "channel"] } +tokio-rustls = { version = "0.26", default-features = false } tower = { version = "0.5", features = ["util"] } tracing = "0.1" url = "2.5" diff --git a/crates/client/src/envconfig.rs b/crates/client/src/envconfig.rs index 08d8ebb76..1b3a3fc50 100644 --- a/crates/client/src/envconfig.rs +++ b/crates/client/src/envconfig.rs @@ -89,6 +89,7 @@ fn build_tls_options(tls: ClientConfigTLS) -> Result { server_root_ca_cert, domain: tls.server_name, client_tls_options, + server_cert_verifier: None, }) } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 906469137..b7caccd9b 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -40,6 +40,13 @@ pub use metrics::{LONG_REQUEST_LATENCY_HISTOGRAM_NAME, REQUEST_LATENCY_HISTOGRAM pub use options_structs::*; pub use replaceable::SharedReplaceableClient; pub use retry::RetryOptions; +/// Potentially dangerous TLS related functionality. +pub mod danger { + /// Re-export the `ServerCertVerifier` trait so that users can implement custom TLS + /// server certificate verification without depending on `tokio-rustls` directly, + /// while explicitly acknowledging the danger in the import path. + pub use tokio_rustls::rustls::client::danger::ServerCertVerifier; +} pub use tonic; pub use workflow_handle::{ UntypedQuery, UntypedSignal, UntypedUpdate, UntypedWorkflow, UntypedWorkflowHandle, @@ -408,13 +415,21 @@ async fn add_tls_to_channel( mut channel: Endpoint, ) -> 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( + "Cannot set both `server_root_ca_cert` and `server_cert_verifier`".to_owned(), + )); + } + let mut tls = tonic::transport::ClientTlsConfig::new(); - if let Some(root_cert) = &tls_cfg.server_root_ca_cert { - let server_root_ca_cert = Certificate::from_pem(root_cert); - tls = tls.ca_certificate(server_root_ca_cert); - } else { - tls = tls.with_native_roots(); + if tls_cfg.server_cert_verifier.is_none() { + if let Some(root_cert) = &tls_cfg.server_root_ca_cert { + let server_root_ca_cert = Certificate::from_pem(root_cert); + tls = tls.ca_certificate(server_root_ca_cert); + } else { + tls = tls.with_native_roots(); + } } if let Some(domain) = &tls_cfg.domain { @@ -434,7 +449,13 @@ async fn add_tls_to_channel( tls = tls.identity(client_identity); } - return channel.tls_config(tls).map_err(Into::into); + return if let Some(verifier) = &tls_cfg.server_cert_verifier { + channel + .tls_config_with_verifier(tls, verifier.clone()) + .map_err(Into::into) + } else { + channel.tls_config(tls).map_err(Into::into) + }; } Ok(channel) } @@ -1485,6 +1506,109 @@ mod tests { assert!(opts.keep_alive.is_none()); } + mod tls_custom_verifier_tests { + use super::*; + use tokio_rustls::rustls::{ + DigitallySignedStruct, Error as RustlsError, SignatureScheme, + client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + pki_types::{CertificateDer, ServerName, UnixTime}, + }; + + /// A minimal mock verifier for testing. In production, users would + /// implement real certificate pinning or custom validation here. + #[derive(Debug)] + struct MockVerifier; + + impl ServerCertVerifier for MockVerifier { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::RSA_PSS_SHA256, + ] + } + } + + #[tokio::test] + async fn add_tls_to_channel_with_custom_verifier() { + let tls_opts = TlsOptions { + 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!( + result.is_ok(), + "add_tls_to_channel should succeed with a custom verifier: {:?}", + result.err() + ); + } + + #[tokio::test] + async fn add_tls_to_channel_with_verifier_and_ca_cert_fails() { + // When both server_cert_verifier and server_root_ca_cert are set, + // add_tls_to_channel should fail with InvalidConfig. + let tls_opts = TlsOptions { + server_root_ca_cert: Some(b"some-ca-cert-bytes".to_vec()), + 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, Err(ClientConnectError::InvalidConfig(_))), + "add_tls_to_channel should fail with InvalidConfig when both CA cert and verifier are set: {:?}", + result + ); + } + + #[tokio::test] + async fn add_tls_to_channel_without_verifier_still_works() { + // Regression test: the original PEM path must still work. + let tls_opts = TlsOptions { + 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!( + result.is_ok(), + "add_tls_to_channel should succeed without a verifier (native roots): {:?}", + result.err() + ); + } + } + mod list_workflows_tests { use super::*; use futures_util::{FutureExt, StreamExt}; diff --git a/crates/client/src/options_structs.rs b/crates/client/src/options_structs.rs index a05923c98..558fff79b 100644 --- a/crates/client/src/options_structs.rs +++ b/crates/client/src/options_structs.rs @@ -1,6 +1,6 @@ use crate::{HttpConnectProxyOptions, RetryOptions, VERSION, callback_based}; use http::Uri; -use std::{collections::HashMap, time::Duration}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use temporalio_common::{ data_converters::DataConverter, protos::temporal::api::{ @@ -17,6 +17,7 @@ use temporalio_common::{ }, telemetry::metrics::TemporalMeter, }; +use tokio_rustls::rustls::client::danger::ServerCertVerifier; use url::Url; /// Options for [crate::Connection::connect]. @@ -132,7 +133,7 @@ pub struct ClientOptions { } /// Configuration options for TLS -#[derive(Clone, Debug, Default)] +#[derive(Clone, Default)] pub struct TlsOptions { /// Bytes representing the root CA certificate used by the server. If not set, and the server's /// cert is issued by someone the operating system trusts, verification will still work (ex: @@ -143,6 +144,43 @@ pub struct TlsOptions { pub domain: Option, /// TLS info for the client. If specified, core will attempt to use mTLS. 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. + /// + /// This is useful for: + /// - Certificate pinning + /// - Custom trust-domain validation (e.g., SAN-URI extraction) + /// - Federated root certificate stores + /// + /// # WARNING + /// Implementing a custom `ServerCertVerifier` can lead to severely insecure TLS connections + /// (e.g., disabling all validation or allowing man-in-the-middle attacks) if not done carefully. + /// Only use this if you know exactly what you are doing. + /// + /// The verifier must implement [`ServerCertVerifier`] from the `rustls` crate. + /// Note that `domain` is still respected for the `:authority` header / origin override + /// even when a custom verifier is set. + pub server_cert_verifier: Option>, +} + +impl std::fmt::Debug for TlsOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TlsOptions") + .field( + "server_root_ca_cert", + &self + .server_root_ca_cert + .as_ref() + .map(|c| format!("{} bytes", c.len())), + ) + .field("domain", &self.domain) + .field("client_tls_options", &self.client_tls_options) + .field( + "server_cert_verifier", + &self.server_cert_verifier.as_ref().map(|_| ""), + ) + .finish() + } } /// If using mTLS, both the client cert and private key must be specified, this contains them. diff --git a/crates/sdk-core-c-bridge/src/client.rs b/crates/sdk-core-c-bridge/src/client.rs index a050533bd..674a85ad3 100644 --- a/crates/sdk-core-c-bridge/src/client.rs +++ b/crates/sdk-core-c-bridge/src/client.rs @@ -1432,6 +1432,7 @@ impl TryFrom<&ClientTlsOptions> for temporalio_client::TlsOptions { )); } }, + server_cert_verifier: None, }) } } diff --git a/crates/sdk-core/tests/common/mod.rs b/crates/sdk-core/tests/common/mod.rs index 7afe1c0e8..e3ff5e901 100644 --- a/crates/sdk-core/tests/common/mod.rs +++ b/crates/sdk-core/tests/common/mod.rs @@ -824,6 +824,7 @@ pub(crate) fn get_integ_tls_config() -> Option { client_cert, client_private_key, }), + server_cert_verifier: None, }) } else { None