Files
Joseph Doherty cdfad420bb fix(client-rust): apply TLS guard to GalaxyClient and add CLI strict flag
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.
2026-06-01 07:28:16 -04:00

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-----
";