cdfad420bb
Extract the TLS-without-CA guard into a shared `build_tls_config` helper in options.rs so both GatewayClient and GalaxyClient use identical logic. GalaxyClient previously had no guard, so TLS-without-CA produced a cryptic tonic handshake failure; it now returns the same actionable InvalidEndpoint error. The guard message notes that a server-name override affects SNI but does not pin trust. Add --require-certificate-validation to ConnectionArgs in the CLI binary. Add a mirror test for GalaxyClient in tests/tls.rs.
138 lines
5.6 KiB
Rust
138 lines
5.6 KiB
Rust
//! 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, GalaxyClient, 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:?}"
|
|
);
|
|
}
|
|
|
|
/// Drive `GalaxyClient::connect` to its error (mirrors `connect_err` above).
|
|
async fn galaxy_connect_err(options: ClientOptions) -> Error {
|
|
match GalaxyClient::connect(options).await {
|
|
Ok(_client) => {
|
|
panic!("GalaxyClient::connect unexpectedly succeeded against a dead TLS address")
|
|
}
|
|
Err(error) => error,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn galaxy_tls_without_ca_is_rejected_with_actionable_error_by_default() {
|
|
// GalaxyClient::connect must apply the same TLS guard as GatewayClient —
|
|
// TLS without a pinned CA (and without require_certificate_validation)
|
|
// returns a clear, actionable InvalidEndpoint error.
|
|
let options = ClientOptions::new("https://127.0.0.1:1")
|
|
.with_plaintext(false)
|
|
.with_connect_timeout(Duration::from_millis(200));
|
|
|
|
let error = galaxy_connect_err(options).await;
|
|
|
|
let Error::InvalidEndpoint { detail, .. } = error else {
|
|
panic!("expected InvalidEndpoint, got {error:?}");
|
|
};
|
|
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}"
|
|
);
|
|
}
|
|
|
|
/// 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-----
|
|
";
|