Skip to content

Commit 0fe762f

Browse files
authored
Add TLSConfig.verification_server_name to decouple certificate verification from SNI (#1651)
When set, the server certificate is verified against this fixed name (using server_root_ca_cert) via a custom rustls ServerCertVerifier built in the bridge, while SNI and the HTTP/2 authority keep following the connected host (or domain, if set). This supports servers whose certificate does not carry the dialed name without overriding SNI, e.g. when the connection traverses an SNI-inspecting proxy that must be able to resolve the SNI value.
1 parent b77ebfa commit 0fe762f

9 files changed

Lines changed: 366 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ to include examples, links to docs, or any other relevant information.
2020

2121
### Added
2222

23+
- Added `TLSConfig.verification_server_name` to verify the server certificate against a fixed name
24+
instead of the connection's server name. Unlike `domain`, it does not change the TLS SNI or
25+
HTTP/2 authority values, which keep following the connected host, so it can be used when the
26+
server's certificate does not carry the dialed name but on-path infrastructure (e.g. an
27+
SNI-inspecting egress proxy) needs the SNI to remain resolvable. Requires
28+
`server_root_ca_cert`.
29+
2330
- Added the experimental `Worker` `patch_activation_callback` option, allowing workers
2431
to decide whether a first non-replay `workflow.patched` call should activate a patch
2532
during rolling deployments.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Documentation = "https://docs.temporal.io/docs/python"
5353
dev = [
5454
"basedpyright==1.34.0",
5555
"cibuildwheel>=2.22.0,<3",
56+
"cryptography>=46",
5657
"grpcio-tools>=1.48.2,<2",
5758
"mypy==1.18.2",
5859
"mypy-protobuf>=3.3.0,<4",

temporalio/bridge/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

temporalio/bridge/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ temporalio-sdk-core = { version = "0.5", path = "./sdk-core/crates/sdk-core", fe
3636
"ephemeral-server",
3737
] }
3838
tokio = "1.26"
39+
# Matches the sdk-core client crate's rustls stack; used to build the custom
40+
# server certificate verifier for ClientTlsConfig.verification_server_name.
41+
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
3942
tokio-stream = "0.1"
4043
tonic = "0.14"
4144
tracing = "0.1"

temporalio/bridge/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class ClientTlsConfig:
2727
domain: str | None
2828
client_cert: bytes | None
2929
client_private_key: bytes | None
30+
verification_server_name: str | None
3031

3132

3233
@dataclass

temporalio/bridge/src/client.rs

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use pyo3::exceptions::{PyException, PyRuntimeError, PyValueError};
22
use pyo3::prelude::*;
33
use std::collections::HashMap;
44
use std::str::FromStr;
5+
use std::sync::Arc;
56
use std::time::Duration;
67
use temporalio_client::tonic::{
78
self,
@@ -11,6 +12,14 @@ use temporalio_client::{
1112
ClientKeepAliveOptions as CoreClientKeepAliveConfig, Connection, ConnectionOptions,
1213
DnsLoadBalancingOptions, GrpcCompression, HttpConnectProxyOptions, RetryOptions,
1314
};
15+
use tokio_rustls::rustls::client::danger::{
16+
HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier,
17+
};
18+
use tokio_rustls::rustls::client::WebPkiServerVerifier;
19+
use tokio_rustls::rustls::crypto::CryptoProvider;
20+
use tokio_rustls::rustls::pki_types::pem::PemObject;
21+
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
22+
use tokio_rustls::rustls::{self, DigitallySignedStruct, RootCertStore, SignatureScheme};
1423
use tracing::warn;
1524
use url::Url;
1625

@@ -48,6 +57,7 @@ struct ClientTlsConfig {
4857
domain: Option<String>,
4958
client_cert: Option<Vec<u8>>,
5059
client_private_key: Option<Vec<u8>>,
60+
verification_server_name: Option<String>,
5161
}
5262

5363
#[derive(FromPyObject)]
@@ -301,8 +311,22 @@ impl TryFrom<ClientTlsConfig> for temporalio_client::TlsOptions {
301311
type Error = PyErr;
302312

303313
fn try_from(conf: ClientTlsConfig) -> PyResult<Self> {
314+
let mut server_root_ca_cert = conf.server_root_ca_cert;
315+
let server_cert_verifier = match conf.verification_server_name {
316+
None => None,
317+
Some(name) => {
318+
// The CA bundle is consumed by the verifier's own root store; a
319+
// custom verifier cannot be combined with roots on the connection.
320+
let ca_cert = server_root_ca_cert.take().ok_or_else(|| {
321+
PyValueError::new_err(
322+
"Must have server root CA cert when verification server name is set",
323+
)
324+
})?;
325+
Some(fixed_server_name_verifier(&name, &ca_cert)?)
326+
}
327+
};
304328
Ok(temporalio_client::TlsOptions {
305-
server_root_ca_cert: conf.server_root_ca_cert,
329+
server_root_ca_cert,
306330
domain: conf.domain,
307331
client_tls_options: match (conf.client_cert, conf.client_private_key) {
308332
(None, None) => None,
@@ -318,11 +342,92 @@ impl TryFrom<ClientTlsConfig> for temporalio_client::TlsOptions {
318342
))
319343
}
320344
},
321-
server_cert_verifier: None,
345+
server_cert_verifier,
322346
})
323347
}
324348
}
325349

350+
/// Builds a standard WebPKI verifier over the given root CA bundle that
351+
/// checks the certificate against `verification_server_name` rather than the
352+
/// connection's server name, leaving SNI/`:authority` to follow the
353+
/// connected host (or `domain` when set).
354+
fn fixed_server_name_verifier(
355+
verification_server_name: &str,
356+
ca_cert_pem: &[u8],
357+
) -> PyResult<Arc<dyn ServerCertVerifier>> {
358+
let certs = CertificateDer::pem_slice_iter(ca_cert_pem)
359+
.collect::<Result<Vec<_>, _>>()
360+
.map_err(|err| {
361+
PyValueError::new_err(format!("Invalid server root CA cert PEM: {err:?}"))
362+
})?;
363+
// Root loading and provider selection mirror tonic's default (no custom
364+
// verifier) client path: unparsable certificates in the bundle are
365+
// skipped, and the provider is the process default if one is installed,
366+
// else ring, as with the connection's `tls-ring` feature.
367+
let mut roots = RootCertStore::empty();
368+
roots.add_parsable_certificates(certs);
369+
let provider = CryptoProvider::get_default()
370+
.cloned()
371+
.unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider()));
372+
let inner = WebPkiServerVerifier::builder_with_provider(roots.into(), provider)
373+
.build()
374+
.map_err(|err| {
375+
PyValueError::new_err(format!("Failed building certificate verifier: {err}"))
376+
})?;
377+
let server_name = ServerName::try_from(verification_server_name.to_owned())
378+
.map_err(|err| PyValueError::new_err(format!("Invalid verification server name: {err}")))?;
379+
Ok(Arc::new(FixedServerNameVerifier { inner, server_name }))
380+
}
381+
382+
/// Delegates to the standard WebPKI verifier, but verifies the certificate
383+
/// against a fixed server name instead of the connection's server name.
384+
#[derive(Debug)]
385+
struct FixedServerNameVerifier {
386+
inner: Arc<WebPkiServerVerifier>,
387+
server_name: ServerName<'static>,
388+
}
389+
390+
impl ServerCertVerifier for FixedServerNameVerifier {
391+
fn verify_server_cert(
392+
&self,
393+
end_entity: &CertificateDer<'_>,
394+
intermediates: &[CertificateDer<'_>],
395+
_server_name: &ServerName<'_>,
396+
ocsp_response: &[u8],
397+
now: UnixTime,
398+
) -> Result<ServerCertVerified, rustls::Error> {
399+
self.inner.verify_server_cert(
400+
end_entity,
401+
intermediates,
402+
&self.server_name,
403+
ocsp_response,
404+
now,
405+
)
406+
}
407+
408+
fn verify_tls12_signature(
409+
&self,
410+
message: &[u8],
411+
cert: &CertificateDer<'_>,
412+
dss: &DigitallySignedStruct,
413+
) -> Result<HandshakeSignatureValid, rustls::Error> {
414+
self.inner.verify_tls12_signature(message, cert, dss)
415+
}
416+
417+
fn verify_tls13_signature(
418+
&self,
419+
message: &[u8],
420+
cert: &CertificateDer<'_>,
421+
dss: &DigitallySignedStruct,
422+
) -> Result<HandshakeSignatureValid, rustls::Error> {
423+
self.inner.verify_tls13_signature(message, cert, dss)
424+
}
425+
426+
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
427+
self.inner.supported_verify_schemes()
428+
}
429+
}
430+
326431
impl From<ClientRetryConfig> for RetryOptions {
327432
fn from(conf: ClientRetryConfig) -> Self {
328433
RetryOptions {

temporalio/service.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ class TLSConfig:
4343
"""Root CA to validate the server certificate against."""
4444

4545
domain: str | None = None
46-
"""TLS domain."""
46+
"""SNI host and HTTP/2 authority override, and the default name the server
47+
certificate is verified against (see
48+
:py:attr:`verification_server_name`)."""
4749

4850
client_cert: bytes | None = None
4951
"""Client certificate for mTLS.
@@ -55,12 +57,26 @@ class TLSConfig:
5557
5658
This must be combined with :py:attr:`client_cert`."""
5759

60+
verification_server_name: str | None = None
61+
"""Name to verify the server certificate against, instead of the
62+
:py:attr:`domain` / connected host.
63+
64+
Unlike :py:attr:`domain`, this does not change the TLS SNI or HTTP/2
65+
authority values, which continue to follow the connected host (or
66+
:py:attr:`domain` if set). Use this when the server's certificate does not
67+
carry the name being dialed, e.g. when the connection traverses an
68+
SNI-inspecting proxy that must be able to resolve the SNI value.
69+
70+
Requires :py:attr:`server_root_ca_cert`; the system root store is not
71+
consulted when this is set."""
72+
5873
def _to_bridge_config(self) -> temporalio.bridge.client.ClientTlsConfig:
5974
return temporalio.bridge.client.ClientTlsConfig(
6075
server_root_ca_cert=self.server_root_ca_cert,
6176
domain=self.domain,
6277
client_cert=self.client_cert,
6378
client_private_key=self.client_private_key,
79+
verification_server_name=self.verification_server_name,
6480
)
6581

6682

0 commit comments

Comments
 (0)