Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/client/src/envconfig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ fn build_tls_options(tls: ClientConfigTLS) -> Result<TlsOptions, ConfigError> {
server_root_ca_cert,
domain: tls.server_name,
client_tls_options,
server_cert_verifier: None,
})
}

Expand Down
136 changes: 130 additions & 6 deletions crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -408,13 +415,21 @@ async fn add_tls_to_channel(
mut channel: Endpoint,
) -> Result<Endpoint, ClientConnectError> {
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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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<ServerCertVerified, RustlsError> {
Ok(ServerCertVerified::assertion())
}

fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
Ok(HandshakeSignatureValid::assertion())
}

fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
Ok(HandshakeSignatureValid::assertion())
}

fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
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};
Expand Down
42 changes: 40 additions & 2 deletions crates/client/src/options_structs.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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].
Expand Down Expand Up @@ -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:
Expand All @@ -143,6 +144,43 @@ pub struct TlsOptions {
pub domain: Option<String>,
/// TLS info for the client. If specified, core will attempt to use mTLS.
pub client_tls_options: Option<ClientTlsOptions>,
/// 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
Comment on lines +150 to +153
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to include some warning here about the fact that this allows you to make dangerously insecure connections

///
/// # 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<Arc<dyn ServerCertVerifier>>,
}

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(|_| "<custom>"),
)
.finish()
}
}

/// If using mTLS, both the client cert and private key must be specified, this contains them.
Expand Down
1 change: 1 addition & 0 deletions crates/sdk-core-c-bridge/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,7 @@ impl TryFrom<&ClientTlsOptions> for temporalio_client::TlsOptions {
));
}
},
server_cert_verifier: None,
})
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/sdk-core/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,7 @@ pub(crate) fn get_integ_tls_config() -> Option<TlsOptions> {
client_cert,
client_private_key,
}),
server_cert_verifier: None,
})
} else {
None
Expand Down
Loading