feat(client-rust): accept gateway cert by default over TLS (or documented pin-only fallback)
This commit is contained in:
@@ -89,6 +89,26 @@ impl GatewayClient {
|
|||||||
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
||||||
})?;
|
})?;
|
||||||
tls = tls.ca_certificate(Certificate::from_pem(certificate));
|
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)?;
|
endpoint = endpoint.tls_config(tls)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub struct ClientOptions {
|
|||||||
api_key: Option<ApiKey>,
|
api_key: Option<ApiKey>,
|
||||||
plaintext: bool,
|
plaintext: bool,
|
||||||
ca_file: Option<PathBuf>,
|
ca_file: Option<PathBuf>,
|
||||||
|
require_certificate_validation: bool,
|
||||||
server_name_override: Option<String>,
|
server_name_override: Option<String>,
|
||||||
connect_timeout: Duration,
|
connect_timeout: Duration,
|
||||||
call_timeout: Duration,
|
call_timeout: Duration,
|
||||||
@@ -38,6 +39,7 @@ impl ClientOptions {
|
|||||||
api_key: None,
|
api_key: None,
|
||||||
plaintext: true,
|
plaintext: true,
|
||||||
ca_file: None,
|
ca_file: None,
|
||||||
|
require_certificate_validation: false,
|
||||||
server_name_override: None,
|
server_name_override: None,
|
||||||
connect_timeout: Duration::from_secs(10),
|
connect_timeout: Duration::from_secs(10),
|
||||||
call_timeout: Duration::from_secs(30),
|
call_timeout: Duration::from_secs(30),
|
||||||
@@ -67,6 +69,22 @@ impl ClientOptions {
|
|||||||
self
|
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
|
/// Override the SNI/server name used during the TLS handshake. Useful
|
||||||
/// when the dial-target host name does not match the certificate.
|
/// when the dial-target host name does not match the certificate.
|
||||||
pub fn with_server_name_override(mut self, server_name_override: impl Into<String>) -> Self {
|
pub fn with_server_name_override(mut self, server_name_override: impl Into<String>) -> Self {
|
||||||
@@ -121,6 +139,12 @@ impl ClientOptions {
|
|||||||
self.ca_file.as_ref()
|
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.
|
/// Optional SNI / server-name override for TLS handshakes.
|
||||||
pub fn server_name_override(&self) -> Option<&str> {
|
pub fn server_name_override(&self) -> Option<&str> {
|
||||||
self.server_name_override.as_deref()
|
self.server_name_override.as_deref()
|
||||||
@@ -161,6 +185,10 @@ impl fmt::Debug for ClientOptions {
|
|||||||
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||||
.field("plaintext", &self.plaintext)
|
.field("plaintext", &self.plaintext)
|
||||||
.field("ca_file", &self.ca_file)
|
.field("ca_file", &self.ca_file)
|
||||||
|
.field(
|
||||||
|
"require_certificate_validation",
|
||||||
|
&self.require_certificate_validation,
|
||||||
|
)
|
||||||
.field("server_name_override", &self.server_name_override)
|
.field("server_name_override", &self.server_name_override)
|
||||||
.field("connect_timeout", &self.connect_timeout)
|
.field("connect_timeout", &self.connect_timeout)
|
||||||
.field("call_timeout", &self.call_timeout)
|
.field("call_timeout", &self.call_timeout)
|
||||||
|
|||||||
@@ -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-----
|
||||||
|
";
|
||||||
Reference in New Issue
Block a user