fix(client/rust): handle provider_status arm (build break); real system-roots TLS; design doc (Client.Rust-030..032)
This commit is contained in:
+141
-44
@@ -74,16 +74,22 @@ impl ClientOptions {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// false. Setting a CA file always verifies against that CA.
|
||||
///
|
||||
/// Note for Rust: tonic 0.13's `ClientTlsConfig` exposes no hook for a
|
||||
/// custom rustls verifier, so the Rust client cannot accept an arbitrary
|
||||
/// 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.
|
||||
/// rejects the TLS connection and asks for a CA file. There are two
|
||||
/// supported TLS paths:
|
||||
///
|
||||
/// - Pin the gateway certificate with [`ClientOptions::with_ca_file`] (the
|
||||
/// lenient pin-only path; works for a self-signed gateway cert).
|
||||
/// - Set this `true` to verify against the operating system's trust roots
|
||||
/// (`tls-native-roots`). This only succeeds for a certificate that chains
|
||||
/// to a root the host already trusts, so it is for gateways fronted by a
|
||||
/// publicly- or enterprise-CA-issued certificate, not a bare self-signed
|
||||
/// one.
|
||||
pub fn with_require_certificate_validation(mut self, require: bool) -> Self {
|
||||
self.require_certificate_validation = require;
|
||||
self
|
||||
@@ -175,26 +181,63 @@ impl ClientOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the TLS handshake gets its trust anchors for a given set of options.
|
||||
/// Computed by [`tls_trust_decision`] and applied by [`build_tls_config`];
|
||||
/// split out so the trust posture is unit-testable without a live handshake.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum TlsTrustDecision {
|
||||
/// Plaintext transport — no TLS, no trust anchors.
|
||||
None,
|
||||
/// Validate against the CA pinned with [`ClientOptions::with_ca_file`].
|
||||
PinnedCa,
|
||||
/// Validate against the operating system's trust roots
|
||||
/// (`require_certificate_validation == true`, no pinned CA).
|
||||
SystemRoots,
|
||||
/// Reject up front: TLS requested with neither a pinned CA nor strict
|
||||
/// verification (the Rust pin-only lenient default).
|
||||
RejectNoCa,
|
||||
}
|
||||
|
||||
/// Decide the TLS trust posture from `options` without touching the filesystem
|
||||
/// or the network.
|
||||
pub(crate) fn tls_trust_decision(options: &ClientOptions) -> TlsTrustDecision {
|
||||
if options.plaintext() {
|
||||
TlsTrustDecision::None
|
||||
} else if options.ca_file().is_some() {
|
||||
TlsTrustDecision::PinnedCa
|
||||
} else if options.require_certificate_validation() {
|
||||
TlsTrustDecision::SystemRoots
|
||||
} else {
|
||||
TlsTrustDecision::RejectNoCa
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the [`ClientTlsConfig`] for a non-plaintext connection described by
|
||||
/// `options`, applying the lenient-default guard that is the **Rust
|
||||
/// pin-only exception**.
|
||||
///
|
||||
/// Returns `Ok(None)` when `options.plaintext()` is `true` (no TLS needed).
|
||||
/// Returns `Ok(Some(tls))` when a valid TLS config can be assembled.
|
||||
/// Returns `Ok(Some(tls))` when a valid TLS config can be assembled — either
|
||||
/// pinned to the CA from [`ClientOptions::with_ca_file`], or, when
|
||||
/// `require_certificate_validation` is set with no pinned CA, verifying against
|
||||
/// the operating system's trust roots (`tls-native-roots`).
|
||||
/// Returns `Err(Error::InvalidEndpoint)` when TLS is requested but no pinned
|
||||
/// CA was provided and `require_certificate_validation` is `false`.
|
||||
///
|
||||
/// # Why this guard exists
|
||||
/// # Why the no-CA guard exists
|
||||
///
|
||||
/// `tonic` 0.13's `ClientTlsConfig` builds its rustls verifier inside a
|
||||
/// crate-private connector and exposes no hook for a custom
|
||||
/// `ServerCertVerifier`. The Rust client therefore cannot accept an arbitrary
|
||||
/// `ServerCertVerifier`. The Rust client therefore cannot accept an *arbitrary*
|
||||
/// self-signed certificate the way the other language clients do. Rather than
|
||||
/// silently falling back to system-root verification (which always fails
|
||||
/// against a self-signed gateway certificate), we reject the configuration
|
||||
/// early with an actionable error.
|
||||
/// silently falling back to a verifier with no trust anchors (which rejects
|
||||
/// every certificate with a confusing handshake error), the lenient default
|
||||
/// rejects the configuration early with an actionable error. The strict opt-in
|
||||
/// instead loads the system trust roots so a certificate chaining to an
|
||||
/// already-trusted root validates.
|
||||
pub(crate) fn build_tls_config(options: &ClientOptions) -> Result<Option<ClientTlsConfig>, Error> {
|
||||
if options.plaintext() {
|
||||
let decision = tls_trust_decision(options);
|
||||
if decision == TlsTrustDecision::None {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -202,37 +245,46 @@ pub(crate) fn build_tls_config(options: &ClientOptions) -> Result<Option<ClientT
|
||||
if let Some(server_name) = options.server_name_override() {
|
||||
tls = tls.domain_name(server_name.to_owned());
|
||||
}
|
||||
if let Some(ca_file) = options.ca_file() {
|
||||
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
|
||||
endpoint: options.endpoint().to_owned(),
|
||||
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).
|
||||
//
|
||||
// Note: a server-name override affects SNI (the hostname sent
|
||||
// in the TLS ClientHello) but does NOT pin trust. Overriding
|
||||
// the server name alone does not bypass certificate validation.
|
||||
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. Note: a server-name override \
|
||||
affects SNI but does not pin trust."
|
||||
.to_owned(),
|
||||
});
|
||||
match decision {
|
||||
TlsTrustDecision::PinnedCa => {
|
||||
let ca_file = options.ca_file().expect("PinnedCa implies a CA file");
|
||||
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
|
||||
endpoint: options.endpoint().to_owned(),
|
||||
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
||||
})?;
|
||||
tls = tls.ca_certificate(Certificate::from_pem(certificate));
|
||||
}
|
||||
TlsTrustDecision::SystemRoots => {
|
||||
// Strict opt-in with no pinned CA: verify against the OS trust
|
||||
// store. Without this the bare `ClientTlsConfig` carries zero
|
||||
// trust anchors and rejects every certificate, so the documented
|
||||
// "verify against the system trust roots" behaviour would be
|
||||
// unreachable. Only a certificate chaining to an already-trusted
|
||||
// root validates — a bare self-signed gateway cert still needs
|
||||
// `with_ca_file`.
|
||||
tls = tls.with_native_roots();
|
||||
}
|
||||
TlsTrustDecision::RejectNoCa => {
|
||||
// Lenient-default fallback (Rust pin-only exception): the Rust
|
||||
// client cannot accept an arbitrary self-signed cert. Pin the
|
||||
// gateway's CA, or opt into strict verification against the
|
||||
// system trust roots.
|
||||
//
|
||||
// Note: a server-name override affects SNI (the hostname sent in
|
||||
// the TLS ClientHello) but does NOT pin trust.
|
||||
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. Note: a server-name override \
|
||||
affects SNI but does not pin trust."
|
||||
.to_owned(),
|
||||
});
|
||||
}
|
||||
TlsTrustDecision::None => unreachable!("handled above"),
|
||||
}
|
||||
Ok(Some(tls))
|
||||
}
|
||||
@@ -269,6 +321,8 @@ mod tests {
|
||||
use super::ClientOptions;
|
||||
use crate::auth::ApiKey;
|
||||
|
||||
use super::{build_tls_config, tls_trust_decision, TlsTrustDecision};
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_api_key() {
|
||||
let options =
|
||||
@@ -279,4 +333,47 @@ mod tests {
|
||||
assert!(debug.contains("<redacted>"));
|
||||
assert!(!debug.contains("mxgw_secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_needs_no_tls() {
|
||||
let options = ClientOptions::new("http://127.0.0.1:5000").with_plaintext(true);
|
||||
assert_eq!(tls_trust_decision(&options), TlsTrustDecision::None);
|
||||
assert!(build_tls_config(&options).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_ca_uses_pinned_trust() {
|
||||
let options = ClientOptions::new("https://127.0.0.1:5000")
|
||||
.with_plaintext(false)
|
||||
.with_ca_file("/some/ca.pem");
|
||||
assert_eq!(tls_trust_decision(&options), TlsTrustDecision::PinnedCa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_without_ca_uses_system_roots() {
|
||||
// Regression for Client.Rust-031: strict verification with no pinned CA
|
||||
// must verify against the system trust roots, not produce a config with
|
||||
// zero trust anchors. The trust decision proves roots are consulted; the
|
||||
// build then succeeds (no no-CA guard error) and emits a config.
|
||||
let options = ClientOptions::new("https://127.0.0.1:5000")
|
||||
.with_plaintext(false)
|
||||
.with_require_certificate_validation(true);
|
||||
|
||||
assert_eq!(
|
||||
tls_trust_decision(&options),
|
||||
TlsTrustDecision::SystemRoots,
|
||||
"strict-no-CA must request the system trust roots"
|
||||
);
|
||||
assert!(
|
||||
build_tls_config(&options).unwrap().is_some(),
|
||||
"strict-no-CA must build a usable TLS config"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lenient_without_ca_is_rejected() {
|
||||
let options = ClientOptions::new("https://127.0.0.1:5000").with_plaintext(false);
|
||||
assert_eq!(tls_trust_decision(&options), TlsTrustDecision::RejectNoCa);
|
||||
assert!(build_tls_config(&options).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user