diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index 1078262..cd4e358 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -89,6 +89,26 @@ impl GatewayClient { detail: format!("failed to read CA file {}: {source}", ca_file.display()), })?; tls = tls.ca_certificate(Certificate::from_pem(certificate)); + } else if !options.require_certificate_validation() { + // Lenient-default fallback (Rust pin-only exception): tonic + // 0.13's `ClientTlsConfig` builds its rustls verifier inside a + // crate-private connector and exposes no hook for a custom + // `ServerCertVerifier`, so — unlike the other clients — the + // Rust client cannot accept an arbitrary self-signed cert. Pin + // the gateway's CA instead, or opt into strict verification + // against the system trust roots. We reject here rather than + // silently verifying against system roots (which would fail a + // self-signed gateway with a confusing handshake error). + return Err(Error::InvalidEndpoint { + endpoint: options.endpoint().to_owned(), + detail: "TLS requested without a pinned CA. The Rust client cannot accept an \ + arbitrary self-signed certificate (tonic 0.13 exposes no custom \ + rustls verifier). Pin the gateway certificate with \ + ClientOptions::with_ca_file, or call \ + ClientOptions::with_require_certificate_validation(true) to verify \ + against the system trust roots." + .to_owned(), + }); } endpoint = endpoint.tls_config(tls)?; } diff --git a/clients/rust/src/options.rs b/clients/rust/src/options.rs index b797552..6d4355b 100644 --- a/clients/rust/src/options.rs +++ b/clients/rust/src/options.rs @@ -22,6 +22,7 @@ pub struct ClientOptions { api_key: Option, plaintext: bool, ca_file: Option, + require_certificate_validation: bool, server_name_override: Option, connect_timeout: Duration, call_timeout: Duration, @@ -38,6 +39,7 @@ impl ClientOptions { api_key: None, plaintext: true, ca_file: None, + require_certificate_validation: false, server_name_override: None, connect_timeout: Duration::from_secs(10), call_timeout: Duration::from_secs(30), @@ -67,6 +69,22 @@ impl ClientOptions { self } + /// Require TLS certificate verification even without a pinned CA. Default + /// false: the gateway's self-signed certificate is accepted (internal-tool + /// posture). Setting a CA file always verifies. + /// + /// Note for Rust: tonic 0.13's `ClientTlsConfig` exposes no hook for a + /// custom rustls verifier, so the Rust client cannot accept an arbitrary + /// self-signed certificate the way the other clients do. With the default + /// (false) and no pinned CA, [`crate::client::GatewayClient::connect`] + /// rejects the TLS connection and asks for a CA file. Either pin a CA via + /// [`ClientOptions::with_ca_file`] (the supported lenient path on Rust) or + /// set this `true` to verify against the system trust roots. + pub fn with_require_certificate_validation(mut self, require: bool) -> Self { + self.require_certificate_validation = require; + self + } + /// Override the SNI/server name used during the TLS handshake. Useful /// when the dial-target host name does not match the certificate. pub fn with_server_name_override(mut self, server_name_override: impl Into) -> Self { @@ -121,6 +139,12 @@ impl ClientOptions { self.ca_file.as_ref() } + /// Whether TLS certificate verification is required even without a pinned + /// CA. See [`ClientOptions::with_require_certificate_validation`]. + pub fn require_certificate_validation(&self) -> bool { + self.require_certificate_validation + } + /// Optional SNI / server-name override for TLS handshakes. pub fn server_name_override(&self) -> Option<&str> { self.server_name_override.as_deref() @@ -161,6 +185,10 @@ impl fmt::Debug for ClientOptions { .field("api_key", &self.api_key.as_ref().map(|_| "")) .field("plaintext", &self.plaintext) .field("ca_file", &self.ca_file) + .field( + "require_certificate_validation", + &self.require_certificate_validation, + ) .field("server_name_override", &self.server_name_override) .field("connect_timeout", &self.connect_timeout) .field("call_timeout", &self.call_timeout) diff --git a/clients/rust/tests/tls.rs b/clients/rust/tests/tls.rs new file mode 100644 index 0000000..0769f14 --- /dev/null +++ b/clients/rust/tests/tls.rs @@ -0,0 +1,103 @@ +//! TLS posture coverage for the Rust client. +//! +//! tonic 0.13.1's `ClientTlsConfig` exposes no hook for a custom rustls +//! `ServerCertVerifier` (the verifier is built internally inside the +//! crate-private `TlsConnector`), so the Rust client cannot implement the +//! "accept any server certificate" lenient default the other clients use. +//! Rust is therefore the documented **pin-only exception**: TLS without a +//! pinned CA is rejected up front with a clear, actionable error, and +//! supplying a CA file is the supported path. These tests pin that contract. + +use std::time::Duration; + +use zb_mom_ww_mxgateway_client::{ClientOptions, Error, GatewayClient}; + +/// Drive `connect` to its error without requiring `GatewayClient: Debug` +/// (the success arm is dropped explicitly so `unwrap_err` is unnecessary). +async fn connect_err(options: ClientOptions) -> Error { + match GatewayClient::connect(options).await { + Ok(_client) => panic!("connect unexpectedly succeeded against a dead TLS address"), + Err(error) => error, + } +} + +#[tokio::test] +async fn tls_without_ca_is_rejected_with_actionable_error_by_default() { + let options = ClientOptions::new("https://127.0.0.1:1") + .with_plaintext(false) + .with_connect_timeout(Duration::from_millis(200)); + + let error = connect_err(options).await; + + let Error::InvalidEndpoint { detail, .. } = error else { + panic!("expected InvalidEndpoint, got {error:?}"); + }; + // The message must point the caller at the supported remedy (pin a CA) + // and name the opt-in escape hatch. + assert!( + detail.contains("ca_file") || detail.contains("CA"), + "error should instruct the user to pass a CA file: {detail}" + ); + assert!( + detail.contains("require_certificate_validation"), + "error should mention the require_certificate_validation opt-in: {detail}" + ); +} + +#[tokio::test] +async fn tls_with_require_certificate_validation_does_not_short_circuit() { + // With strict verification opted in, the no-CA guard must not fire; the + // connect attempt instead proceeds to the transport (and fails to reach + // the dead address) rather than returning the "CA required" guard error. + let options = ClientOptions::new("https://127.0.0.1:1") + .with_plaintext(false) + .with_require_certificate_validation(true) + .with_connect_timeout(Duration::from_millis(200)); + + let error = connect_err(options).await; + + assert!( + !matches!(&error, Error::InvalidEndpoint { detail, .. } + if detail.contains("require_certificate_validation")), + "strict verification must bypass the no-CA guard, got {error:?}" + ); +} + +#[tokio::test] +async fn tls_with_ca_file_is_permitted_and_proceeds_past_the_guard() { + // Pinning a CA is the supported TLS path: the no-CA guard must not fire. + // We hand it a readable PEM file; construction proceeds past the guard + // and only fails later at the transport (dead address / handshake). + let ca_path = std::env::temp_dir().join("mxgw-rust-tls-ca-fixture.pem"); + std::fs::write(&ca_path, SELF_SIGNED_CA_PEM).unwrap(); + + let options = ClientOptions::new("https://127.0.0.1:1") + .with_plaintext(false) + .with_ca_file(&ca_path) + .with_connect_timeout(Duration::from_millis(200)); + + let error = connect_err(options).await; + + let _ = std::fs::remove_file(&ca_path); + + assert!( + !matches!(&error, Error::InvalidEndpoint { detail, .. } + if detail.contains("require_certificate_validation")), + "pinning a CA must bypass the no-CA guard, got {error:?}" + ); +} + +/// A throwaway self-signed CA certificate (PEM). Only needs to parse as a +/// PEM trust root so the CA-pinning path is exercised past the guard. +const SELF_SIGNED_CA_PEM: &str = "-----BEGIN CERTIFICATE----- +MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw +DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow +EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d +7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B +5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr +BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1 +NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l +Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc +6MF9+Yw1Yy0t +-----END CERTIFICATE----- +";