diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d1808a60..0587430e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## [Unreleased] +### ✨ Added + +- **`feat(https)`: mutual TLS (client certificate authentication) on HTTPS listeners** ([#1299](https://github.com/sozu-proxy/sozu/issues/1299)). + An HTTPS listener can now require or request a client certificate instead of + always disabling client auth. A new per-listener `client_auth` mode + (`none` / `optional` / `required`) drives the rustls `WebPkiClientVerifier`: + `required` aborts the handshake unless the client presents a certificate + chaining to a configured trusted CA, `optional` requests one but still + accepts connections that present none (a presented certificate must still + validate). New listener fields carry the PEM-encoded trusted CA bundle + (`client_ca_certificates`) and optional revocation lists (`client_ca_crls`). + Absent config decodes to `none`, preserving the previous behavior and keeping + existing state files loadable (new protobuf fields are appended, none + reordered, and default to empty when missing from an older record). + Configuration fails closed: an unknown `client_auth` value, a non-`none` mode + with no trusted CA, an unreadable CA/CRL file, a CA or CRL entry that yields no + certificate/revocation list, or an mTLS field set on a non-HTTPS listener is + rejected rather than silently accepting unauthenticated or unrevoked clients. + CRL expiration is enforced (a CRL past its `nextUpdate` is rejected instead of + trusted). In `none` mode CA/CRL paths are ignored entirely, so a stale path + never blocks the configuration from loading. `HttpsListenerConfig` gains the + `ClientAuthMode` enum and the three fields; `ListenerError::ClientAuth` and + `ConfigError::ClientAuthOnNonHttps` report misconfigured input. + ### 🔐 Security - **`fix(command)`: redact TLS certificate and private-key material from `Debug` output.** diff --git a/command/build.rs b/command/build.rs index da5e2d106..cc87ab337 100644 --- a/command/build.rs +++ b/command/build.rs @@ -73,6 +73,11 @@ pub fn main() { .field_attribute("TcpListenerConfig.answers", "#[serde(default)]") .field_attribute("RequestUdpFrontend.tags", "#[serde(default)]") .field_attribute("RequestTcpFrontend.alpn", "#[serde(default)]") + .field_attribute( + "HttpsListenerConfig.client_ca_certificates", + "#[serde(default)]", + ) + .field_attribute("HttpsListenerConfig.client_ca_crls", "#[serde(default)]") .skip_debug([ "CertificateAndKey", "CertificateSummary", diff --git a/command/src/command.proto b/command/src/command.proto index d31e752b4..8c5b1ab9e 100644 --- a/command/src/command.proto +++ b/command/src/command.proto @@ -648,6 +648,23 @@ message HttpsListenerConfig { // indexed-reference header bomb, where 1-byte indexed references are // amplified into per-entry bookkeeping. Default: 128. optional uint32 h2_max_header_fields = 47; + // Mutual TLS (client certificate authentication) mode for this listener. + // Absent or CLIENT_AUTH_NONE (the field-0 default) keeps the current + // behavior: no CertificateRequest is sent and any client certificate is + // ignored. OPTIONAL requests a client certificate but still accepts + // connections that present none; REQUIRED aborts the handshake when the + // client does not present a certificate chaining to a trusted CA. + optional ClientAuthMode client_auth = 48; + // PEM-encoded trusted CA certificates a client certificate must chain to + // for OPTIONAL/REQUIRED modes. Ignored when client_auth is NONE. Multiple + // entries build a single root store; each entry may itself hold a + // concatenated PEM chain. + repeated string client_ca_certificates = 49; + // Optional PEM-encoded certificate revocation lists (CRLs). When present, + // a presented client certificate is rejected if it (or an intermediate) + // appears revoked. Ignored when client_auth is NONE or no trusted CA is + // configured. + repeated string client_ca_crls = 50; } // details of an TCP listener @@ -1048,6 +1065,21 @@ enum TlsVersion { TLS_V1_3 = 5; } +// Mutual TLS client-certificate authentication policy for an HTTPS listener. +// NONE is field 0 so an absent `client_auth` decodes to the current +// no-client-auth behavior, keeping legacy state files byte-compatible. +enum ClientAuthMode { + // No CertificateRequest; client certificates are neither requested nor + // verified. The historical default. + CLIENT_AUTH_NONE = 0; + // Request a client certificate but accept connections that present none. + // Presented certificates must still chain to a trusted CA. + CLIENT_AUTH_OPTIONAL = 1; + // Require a client certificate chaining to a trusted CA; abort the + // handshake otherwise. + CLIENT_AUTH_REQUIRED = 2; +} + // How a UDP flow is keyed for backend affinity. SOURCE_IP keys on the // client source IP only (all ports from one client pin to one backend); // SOURCE_IP_PORT keys on the full 2-tuple (per-socket affinity). diff --git a/command/src/config.rs b/command/src/config.rs index 70b2f6e85..17bdd5c70 100644 --- a/command/src/config.rs +++ b/command/src/config.rs @@ -62,7 +62,7 @@ use crate::{ certificate::split_certificate_chain, logging::AccessLogFormat, proto::command::{ - ActivateListener, AddBackend, AddCertificate, CertificateAndKey, Cluster, + ActivateListener, AddBackend, AddCertificate, CertificateAndKey, ClientAuthMode, Cluster, CustomHttpAnswers, Header, HeaderPosition, HealthCheckConfig, HstsConfig, HttpListenerConfig, HttpsListenerConfig, ListenerType, LoadBalancingAlgorithms, LoadBalancingParams, LoadMetric, MetricDetail, MetricsConfiguration, PathRule, @@ -393,6 +393,15 @@ pub enum ConfigError { (RFC 6797 §7.2 forbids the header over plaintext HTTP)" )] HstsOnPlainHttp(String), + /// A client-certificate-authentication (mTLS) field was set on a non-HTTPS + /// listener. Client auth is a TLS-termination control; HTTP/TCP/UDP + /// listeners discard it, so sozu rejects it at load time rather than + /// silently starting an unauthenticated listener. + #[error( + "invalid client auth config at {0}: client_auth / client_ca_certificates / \ + client_ca_crls are only valid on HTTPS listeners" + )] + ClientAuthOnNonHttps(String), /// A TCP frontend's `hostname` (mapped to the wire `sni` field) is /// neither an exact hostname nor a single leading `*.` wildcard label /// (sozu-proxy/sozu#1279). Rejects `*.*.example.com`, an embedded `*` @@ -521,6 +530,30 @@ pub enum ConfigError { }, } +/// Config-facing mutual-TLS (client certificate authentication) mode, parsed +/// from the `client_auth` key of an HTTPS listener's TOML section. Serializes as +/// lowercase (`"none"` / `"optional"` / `"required"`) so operators write the +/// documented values rather than the protobuf enum's `SCREAMING_SNAKE_CASE`. +/// Maps to the wire [`ClientAuthMode`] in [`ListenerBuilder::to_tls`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ClientAuthConfig { + #[default] + None, + Optional, + Required, +} + +impl From for ClientAuthMode { + fn from(value: ClientAuthConfig) -> Self { + match value { + ClientAuthConfig::None => ClientAuthMode::ClientAuthNone, + ClientAuthConfig::Optional => ClientAuthMode::ClientAuthOptional, + ClientAuthConfig::Required => ClientAuthMode::ClientAuthRequired, + } + } +} + /// An HTTP, HTTPS or TCP listener as parsed from the `Listeners` section in the toml #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -691,6 +724,19 @@ pub struct ListenerBuilder { /// not exceed the global `buffer_size` (validated at config-load). /// Defaults to [`DEFAULT_SNI_PREREAD_MAX_BYTES`]. pub sni_preread_max_bytes: Option, + /// HTTPS listener only: mutual TLS (client certificate authentication) + /// mode (`none` / `optional` / `required`). Absent and `none` both keep the + /// historical no-client-auth behavior. + pub client_auth: Option, + /// HTTPS listener only: filesystem paths to PEM-encoded trusted CA + /// certificates a client certificate must chain to. Loaded at + /// config-materialization time (like `certificate`/`key`) and inlined as + /// PEM into the resulting [`HttpsListenerConfig`]. + pub client_ca_certificates: Option>, + /// HTTPS listener only: filesystem paths to PEM-encoded CRLs used to + /// reject revoked client certificates. Loaded alongside + /// `client_ca_certificates`. + pub client_ca_crls: Option>, } pub fn default_sticky_name() -> String { @@ -785,6 +831,9 @@ impl ListenerBuilder { max_flows: None, sni_preread_timeout: None, sni_preread_max_bytes: None, + client_auth: None, + client_ca_certificates: None, + client_ca_crls: None, } } @@ -995,6 +1044,28 @@ impl ListenerBuilder { self.request_timeout = Some(self.request_timeout.unwrap_or(config.request_timeout)); } + /// Reject mTLS fields on a non-HTTPS listener. Client auth is an + /// HTTPS-termination control; the HTTP/TCP/UDP conversions have no field to + /// carry it, so an operator who sets it there must get a typed error rather + /// than a silently unauthenticated listener. + fn reject_client_auth_fields(&self, listener_kind: &str) -> Result<(), ConfigError> { + let has_mtls = self + .client_auth + .is_some_and(|m| m != ClientAuthConfig::None) + || self + .client_ca_certificates + .as_ref() + .is_some_and(|v| !v.is_empty()) + || self.client_ca_crls.as_ref().is_some_and(|v| !v.is_empty()); + if has_mtls { + return Err(ConfigError::ClientAuthOnNonHttps(format!( + "{} listener {}", + listener_kind, self.address + ))); + } + Ok(()) + } + /// build an HTTP listener with config timeouts, using defaults if no config is provided pub fn to_http(&mut self, config: Option<&Config>) -> Result { if self.protocol != Some(ListenerProtocol::Http) { @@ -1003,6 +1074,7 @@ impl ListenerBuilder { found: self.protocol.to_owned(), }); } + self.reject_client_auth_fields("HTTP")?; // RFC 6797 §7.2: `Strict-Transport-Security` MUST NOT appear on // plaintext-HTTP responses. Reject an `[hsts]` block on an HTTP @@ -1181,6 +1253,31 @@ impl ListenerBuilder { .map(split_certificate_chain) .unwrap_or_default(); + // mTLS: load the trusted-CA and CRL PEM bundles from disk, inlining + // their contents (like `certificate`/`key` above). Any unreadable path + // aborts materialization: a dropped CA would weaken trust, and a + // dropped CRL would silently disable revocation for certificates the + // operator meant to reject. Failing closed keeps client auth honest. + // + // Files are read only for OPTIONAL/REQUIRED. In NONE (the default) the + // runtime ignores CA/CRL data, so a stale path left in the config must + // not block the whole configuration from loading. + let mode = self.client_auth.unwrap_or_default(); + let client_auth = self + .client_auth + .map(|mode| ClientAuthMode::from(mode) as i32); + let load_pem_paths = |paths: &Option>| -> Result, ConfigError> { + if mode == ClientAuthConfig::None { + return Ok(Vec::new()); + } + paths + .as_ref() + .map(|list| list.iter().map(|path| Config::load_file(path)).collect()) + .unwrap_or_else(|| Ok(Vec::new())) + }; + let client_ca_certificates = load_pem_paths(&self.client_ca_certificates)?; + let client_ca_crls = load_pem_paths(&self.client_ca_crls)?; + let http_answers = self.get_http_answers()?; let answers = self.get_listener_answers()?; @@ -1239,6 +1336,9 @@ impl ListenerBuilder { Some(h) => Some(h.to_proto("listener")?), None => None, }, + client_auth, + client_ca_certificates, + client_ca_crls, }; // POST: the built listener binds the requested address and starts @@ -1299,6 +1399,7 @@ impl ListenerBuilder { found: self.protocol.to_owned(), }); } + self.reject_client_auth_fields("TCP")?; if let Some(config) = config { self.assign_config_timeouts(config); @@ -1360,6 +1461,7 @@ impl ListenerBuilder { found: self.protocol.to_owned(), }); } + self.reject_client_auth_fields("UDP")?; let mut max_rx_datagram_size = self .max_rx_datagram_size @@ -5895,4 +5997,86 @@ mod tests { "both cluster frontends on the udp listener must emit AddUdpFrontend" ); } + + #[test] + fn old_state_file_without_client_auth_fields_deserializes() { + // A state file written before mTLS landed carries no `client_ca_*` + // fields. `#[serde(default)]` (via build.rs) must let it round-trip, + // defaulting the new repeated fields to empty rather than erroring on a + // missing field. + let address = SocketAddress::new_v4(127, 0, 0, 1, 8443); + let config = ListenerBuilder::new_https(address) + .to_tls(None) + .expect("default HTTPS listener config"); + + let mut json: serde_json::Value = + serde_json::to_value(&config).expect("serialize HttpsListenerConfig"); + let obj = json.as_object_mut().expect("object"); + obj.remove("client_ca_certificates"); + obj.remove("client_ca_crls"); + obj.remove("client_auth"); + + let restored: HttpsListenerConfig = + serde_json::from_value(json).expect("old state file must deserialize"); + assert!(restored.client_ca_certificates.is_empty()); + assert!(restored.client_ca_crls.is_empty()); + assert_eq!(restored.client_auth, None); + } + + #[test] + fn client_auth_fields_rejected_on_non_https_listener() { + // A TCP listener carrying client_auth must be rejected, not silently + // started unauthenticated. + let address = SocketAddress::new_v4(127, 0, 0, 1, 9000); + let mut tcp = ListenerBuilder::new_tcp(address); + tcp.client_auth = Some(ClientAuthConfig::Required); + assert!(matches!( + tcp.to_tcp(None), + Err(ConfigError::ClientAuthOnNonHttps(_)) + )); + + // A CA bundle without an explicit mode is equally a misconfiguration. + let mut http = ListenerBuilder::new_http(address); + http.client_ca_certificates = Some(vec!["ca.pem".to_string()]); + assert!(matches!( + http.to_http(None), + Err(ConfigError::ClientAuthOnNonHttps(_)) + )); + } + + #[test] + fn none_mode_ignores_stale_ca_paths() { + // With client auth disabled, an unreadable CA/CRL path must not block + // the HTTPS listener from materializing. + let address = SocketAddress::new_v4(127, 0, 0, 1, 9443); + let mut https = ListenerBuilder::new_https(address); + https.client_auth = Some(ClientAuthConfig::None); + https.client_ca_certificates = Some(vec!["/nonexistent/ca.pem".to_string()]); + https.client_ca_crls = Some(vec!["/nonexistent/crl.pem".to_string()]); + let config = https.to_tls(None).expect("NONE mode must ignore CA paths"); + assert!(config.client_ca_certificates.is_empty()); + assert!(config.client_ca_crls.is_empty()); + } + + #[test] + fn client_auth_config_parses_documented_lowercase_names() { + // The documented TOML values (`none`/`optional`/`required`) must parse, + // not just the protobuf enum's SCREAMING_SNAKE_CASE. + assert_eq!( + serde_json::from_str::("\"required\"").unwrap(), + ClientAuthConfig::Required + ); + assert_eq!( + serde_json::from_str::("\"optional\"").unwrap(), + ClientAuthConfig::Optional + ); + assert_eq!( + serde_json::from_str::("\"none\"").unwrap(), + ClientAuthConfig::None + ); + assert_eq!( + ClientAuthMode::from(ClientAuthConfig::Required), + ClientAuthMode::ClientAuthRequired + ); + } } diff --git a/command/src/proto/mod.rs b/command/src/proto/mod.rs index 83ad2b9cb..711bd4c4b 100644 --- a/command/src/proto/mod.rs +++ b/command/src/proto/mod.rs @@ -520,6 +520,20 @@ impl std::fmt::Debug for command::HttpsListenerConfig { .field("send_x_real_ip", &self.send_x_real_ip) .field("hsts", &self.hsts) .field("h2_max_header_fields", &self.h2_max_header_fields) + .field("client_auth", &self.client_auth) + .field( + "client_ca_certificates_count", + &self.client_ca_certificates.len(), + ) + .field( + "client_ca_certificates_len", + &total_string_len(&self.client_ca_certificates), + ) + .field("client_ca_crls_count", &self.client_ca_crls.len()) + .field( + "client_ca_crls_len", + &total_string_len(&self.client_ca_crls), + ) .finish() } } diff --git a/doc/configure.md b/doc/configure.md index 58f93b9fe..0e051dc06 100644 --- a/doc/configure.md +++ b/doc/configure.md @@ -372,6 +372,67 @@ Path 2 was added after the initial HSTS rollout to fix a silent skip that affect | `http.hsts.listener_default_patched`| counter | A `UpdateHttpsListenerConfig.hsts` patch was applied. Fires once per patch. | | `http.hsts.frontend_refreshed` | counter | An inheriting frontend was refreshed during a listener-default HSTS patch (one increment per refreshed entry). Sum across a patch interval = number of frontends touched by that patch. | +#### mTLS — client certificate authentication + +Mutual TLS makes the client prove its identity during the handshake, on top of the server certificate Sōzu already presents. When enabled on an HTTPS listener, Sōzu sends a TLS `CertificateRequest` and validates the certificate the client returns against a listener-scoped bundle of trusted CAs, optionally consulting certificate revocation lists (CRLs). + +Three keys drive it, all under an `[[listeners]]` entry with `protocol = "https"`: + +```toml +[[listeners]] +protocol = "https" +address = "0.0.0.0:443" + +# "none" (default) | "optional" | "required" +client_auth = "required" + +# Filesystem paths to PEM-encoded CA certificates a client certificate must +# chain to. Required whenever client_auth is not "none". Each file may hold a +# concatenated PEM chain; several files may be listed. +client_ca_certificates = ["/etc/sozu/client-ca.pem"] + +# Optional: filesystem paths to PEM-encoded CRLs. When present, a client +# certificate listed as revoked is rejected. +client_ca_crls = ["/etc/sozu/client-ca.crl.pem"] +``` + +Both path lists are read from disk at config-load and their PEM contents inlined into the listener configuration, so the files are not re-read afterwards. Unlike `certificate` and `key`, whose read errors are logged and skipped, an unreadable CA or CRL path aborts the whole configuration: starting a listener with a reduced trust set or without the revocation data the operator asked for would weaken authentication silently. + +##### Modes + +| `client_auth` | `CertificateRequest` sent | Client presents no certificate | Client presents a certificate | +|---------------|---------------------------|--------------------------------|--------------------------------------------------------| +| `"none"` (default) | No | Connection proceeds | Never requested, never inspected | +| `"optional"` | Yes | Connection proceeds | Fully chain-validated; handshake aborts if it fails | +| `"required"` | Yes | **Handshake aborts** | Fully chain-validated; handshake aborts if it fails | + +`"optional"` is not a bypass: a certificate that *is* presented gets the same validation as under `"required"`. It only tolerates clients that present none, which is the mode to use while migrating a fleet to mTLS. + +A state file written before mTLS existed carries none of the three keys and loads unchanged: the absent `client_auth` decodes to `"none"` and the two path lists default to empty. + +##### Validation matrix + +The configuration is rejected rather than silently degraded in every case below. mTLS that fails open is worse than mTLS that fails to start. + +| Configuration | Outcome | +|----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------| +| `client_auth` with a value other than the three above | **Error** at config-load, from TOML parsing. A typo is never folded to `none`. | +| `client_auth = "optional"` / `"required"` with no trusted CA | **Error** `ListenerError::ClientAuth` when the worker builds the listener — client auth was requested with an empty trust set, which no client could ever satisfy. | +| A `client_ca_certificates` or `client_ca_crls` path cannot be read | **Error** at config-materialization. A dropped CA weakens trust; a dropped CRL silently disables revocation. | +| A CA entry parses to zero certificates (empty file, wrong PEM section) | **Error**. Skipping it would start the listener with a subset of the configured trust anchors, and clients issued by the omitted CA would fail with no visible cause. | +| A CRL entry parses to zero revocation lists | **Error**. Same reasoning: revocation would be silently disabled. | +| `client_auth` / `client_ca_certificates` / `client_ca_crls` on an HTTP, TCP, or UDP listener | **Error** `ConfigError::ClientAuthOnNonHttps` at config-load. Those listeners have no field to carry the policy, so it would be discarded. | +| `client_auth = "none"` with stale CA/CRL paths still present | **Allowed** — the paths are not read at all in `none` mode, so a leftover path never blocks configuration loading. | +| A configured CRL past its `nextUpdate` | **Rejected at handshake.** Sōzu enables `enforce_revocation_expiration()`; rustls defaults to ignoring expiration, which would keep trusting a stale CRL. | + +The rejections above happen at two distinct stages. Reading the CA/CRL files and refusing mTLS keys on a non-HTTPS listener happen at config-load, in the master. Parsing the PEM bodies and building the verifier happen worker-side, when the listener is created. In both cases the listener is never activated: `create_rustls_context` returns an error, so there is no fallback to an unauthenticated listener. + +##### Notes + +- The policy is per-listener, not per-frontend. Every frontend served by an HTTPS listener with `client_auth = "required"` requires a client certificate. +- `UpdateHttpsListenerConfig` carries no mTLS fields, so a hot-reconfig partial update cannot downgrade a running listener's client-auth policy. Changing it means recreating the listener. +- The verifier is built with the same explicitly selected `CryptoProvider` as the server config, so it works in single-provider builds (`crypto-openssl` only) and in multi-provider builds where no process-default provider is installed. + #### Options specific to Rustls based HTTPS listeners ##### Cipher suites @@ -1654,6 +1715,8 @@ immediately after the patch is acknowledged. | `strict_sni_binding` | `bool` | per-handshake | `true` | Require `:authority`/`Host` covered by served cert SAN dNSName (RFC 6125 §6.4.3/6.4.4, CWE-346/CWE-444). Default-cert handshakes fall back to legacy SNI exact-match. Miss → 421 (RFC 9110 §15.5.20). | | `disable_http11` | `bool` | per-handshake | `false` | Drop clients that do not negotiate `h2` via ALPN | +The mTLS keys (`client_auth`, `client_ca_certificates`, `client_ca_crls`) are **not** patchable: `UpdateHttpsListenerConfig` carries no mTLS field, so a partial update can never downgrade a running listener's client-auth policy. Changing it means `RemoveListener` + add. See [mTLS — client certificate authentication](#mtls--client-certificate-authentication). + #### TCP listeners | Field | Type | Mutability class | Default | Notes | diff --git a/doc/upgrade/1.x-to-2.0.md b/doc/upgrade/1.x-to-2.0.md index d997e40db..748c8de59 100644 --- a/doc/upgrade/1.x-to-2.0.md +++ b/doc/upgrade/1.x-to-2.0.md @@ -397,7 +397,7 @@ new `http.302.redirection` and `http.308.redirection` success counters. | Asterisk | Tracked in | Roadmap | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | Backend TLS for H2 / mTLS-to-upstream — h2c-only upstream in 2.0.0 | [#1218](https://github.com/sozu-proxy/sozu/issues/1218) | follow-up release | -| Frontend mTLS termination | [#663](https://github.com/sozu-proxy/sozu/issues/663) | follow-up release | +| Frontend mTLS termination | [#663](https://github.com/sozu-proxy/sozu/issues/663) | shipped via [#1300](https://github.com/sozu-proxy/sozu/pull/1300) | | ACME automation | [#926](https://github.com/sozu-proxy/sozu/issues/926) | follow-up release (HTTP-01 first; DNS-01 providers later) | | OCSP stapling | [#707](https://github.com/sozu-proxy/sozu/issues/707) | follow-up release | | WebAssembly / programmable filter API | [#1178](https://github.com/sozu-proxy/sozu/issues/1178), [#685](https://github.com/sozu-proxy/sozu/issues/685), [#687](https://github.com/sozu-proxy/sozu/issues/687) | future | diff --git a/e2e/src/tests/tls_tests.rs b/e2e/src/tests/tls_tests.rs index 014c4e4ff..f5718edb6 100644 --- a/e2e/src/tests/tls_tests.rs +++ b/e2e/src/tests/tls_tests.rs @@ -18,12 +18,15 @@ use std::{ time::{Duration, Instant}, }; -use rustls::ClientConfig; +use rustls::{ + ClientConfig, + pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}, +}; use sozu_command_lib::{ config::ListenerBuilder, proto::command::{ - ActivateListener, AddCertificate, CertificateAndKey, ListenerType, RequestHttpFrontend, - SocketAddress, request::RequestType, + ActivateListener, AddCertificate, CertificateAndKey, ClientAuthMode, ListenerType, + RequestHttpFrontend, SocketAddress, request::RequestType, }, }; @@ -1674,3 +1677,295 @@ fn test_h2_listener_rejects_alpn_absent() { State::Success ); } + +// ============================================================================ +// Test 8: mTLS — frontend client certificate authentication +// ============================================================================ + +/// Trusted CA and client identity, reused from the TCP-SNI mTLS assets. +/// `mtls-client-cert.pem` is signed by `ca-cert.pem` and carries the +/// `TLS Web Client Authentication` extended key usage. +const MTLS_CA_CERT: &[u8] = include_bytes!("../../assets/tcp_sni/ca-cert.pem"); +const MTLS_CLIENT_CERT: &[u8] = include_bytes!("../../assets/tcp_sni/mtls-client-cert.pem"); +const MTLS_CLIENT_KEY: &[u8] = include_bytes!("../../assets/tcp_sni/mtls-client-key.pem"); + +/// What a client identity does to the handshake, for a given listener mode. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ClientIdentity { + /// `with_no_client_auth()` — the client presents nothing. + None, + /// A certificate chaining to the listener's trusted CA. + Trusted, +} + +/// Drive a real TLS handshake against an HTTPS listener configured with +/// `client_auth = mode`, then send an HTTP/1.1 request and report whether a +/// response came back from the backend. +/// +/// This exercises what the unit tests in `lib/src/https.rs` structurally +/// cannot: those only assert that `client_cert_verifier` builds or errors for +/// a given config. The failure mode that matters is an auth bypass at +/// handshake time — e.g. `allow_unauthenticated()` applied on the wrong +/// branch would still build a verifier and pass every unit test, while +/// silently turning `required` into `optional` on the wire. +/// +/// Returns `Some(response_bytes)` when the exchange completed, `None` when the +/// handshake was rejected. The backend aggregator count is returned alongside +/// so callers can assert the request never reached the cluster on a reject. +fn run_mtls_handshake( + worker_name: &str, + mode: ClientAuthMode, + identity: ClientIdentity, +) -> (Option>, usize) { + let front_port = provide_port(); + let front_address = SocketAddress::new_v4(127, 0, 0, 1, front_port); + let back_address = create_local_address(); + + let (config, listeners, state) = Worker::empty_https_config(front_address.into()); + let mut worker = Worker::start_new_worker_owned(worker_name, config, listeners, state); + + // `ListenerBuilder` exposes no mTLS setter, so mutate the generated + // config in place (same approach as the disable_http11 test above). + // `HttpsListenerConfig` carries inlined PEM, not paths: the TOML loader + // is what turns `client_ca_certificates` paths into these bodies, so a + // test driving the proxy directly supplies the PEM itself. + let mut https_listener = ListenerBuilder::new_https(front_address.clone()) + .to_tls(None) + .expect("could not build HTTPS listener config"); + https_listener.client_auth = Some(mode as i32); + https_listener.client_ca_certificates = + vec![String::from_utf8(MTLS_CA_CERT.to_vec()).expect("CA PEM is valid UTF-8")]; + worker.send_proxy_request_type(RequestType::AddHttpsListener(https_listener)); + + worker.send_proxy_request_type(RequestType::ActivateListener(ActivateListener { + address: front_address.clone(), + proxy: ListenerType::Https.into(), + from_scm: false, + })); + worker.send_proxy_request_type(RequestType::AddCluster(Worker::default_cluster( + "cluster_0", + ))); + worker.send_proxy_request_type(RequestType::AddHttpsFrontend(RequestHttpFrontend { + hostname: "localhost".to_owned(), + ..Worker::default_http_frontend("cluster_0", front_address.clone().into()) + })); + + let certificate_and_key = CertificateAndKey { + certificate: String::from(include_str!("../../../lib/assets/local-certificate.pem")), + key: String::from(include_str!("../../../lib/assets/local-key.pem")), + certificate_chain: vec![], + versions: vec![], + names: vec![], + }; + worker.send_proxy_request_type(RequestType::AddCertificate(AddCertificate { + address: front_address.clone(), + certificate: certificate_and_key, + expired_at: None, + })); + worker.send_proxy_request_type(RequestType::AddBackend(Worker::default_backend( + "cluster_0", + "cluster_0-0", + back_address, + None, + ))); + + let mut backend = AsyncBackend::spawn_detached_backend( + "BACKEND", + back_address, + SimpleAggregator::default(), + AsyncBackend::http_handler("pong"), + ); + + worker.read_to_last(); + + // The server certificate is the test one, so keep the permissive + // `Verifier` on the client side: this test is about CLIENT auth. + let tls_config = match identity { + ClientIdentity::None => ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(Verifier)) + .with_no_client_auth(), + ClientIdentity::Trusted => { + let cert = + CertificateDer::from_pem_slice(MTLS_CLIENT_CERT).expect("parse client cert PEM"); + let key = PrivateKeyDer::from_pem_slice(MTLS_CLIENT_KEY).expect("parse client key PEM"); + ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(Verifier)) + .with_client_auth_cert(vec![cert], key) + .expect("attach client identity cert+key") + } + }; + + let server_name = rustls::pki_types::ServerName::try_from("localhost").unwrap(); + let conn = rustls::ClientConnection::new(Arc::new(tls_config), server_name.to_owned()).unwrap(); + + let addr: SocketAddr = format!("127.0.0.1:{front_port}").parse().unwrap(); + let tcp = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).expect("connect to sozu"); + tcp.set_read_timeout(Some(Duration::from_secs(5))) + .expect("set read timeout"); + tcp.set_write_timeout(Some(Duration::from_secs(5))) + .expect("set write timeout"); + + let mut tls_stream = rustls::StreamOwned::new(conn, tcp); + + // Either the write or the read may surface a handshake rejection, + // depending on which flight the fatal alert lands on. + let request = "GET /api HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + let write_result = tls_stream.write_all(request.as_bytes()); + let _ = tls_stream.flush(); + + // Read until EOF rather than once: a single read sees one segment. + let mut response_bytes = Vec::new(); + let mut buf = [0u8; 1024]; + let start = Instant::now(); + let mut read_failed = false; + loop { + match tls_stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + response_bytes.extend_from_slice(&buf[..n]); + if start.elapsed() > Duration::from_secs(10) { + break; + } + } + Err(ref e) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => { + if start.elapsed() > Duration::from_secs(10) { + break; + } + thread::sleep(Duration::from_millis(20)); + } + Err(_) => { + read_failed = true; + break; + } + } + } + drop(tls_stream); + + worker.soft_stop(); + worker.wait_for_server_stop(); + let aggregator = backend + .stop_and_get_aggregator() + .expect("Could not get aggregator"); + + let rejected = write_result.is_err() || (read_failed && response_bytes.is_empty()); + let outcome = if rejected || response_bytes.is_empty() { + None + } else { + Some(response_bytes) + }; + (outcome, aggregator.requests_received) +} + +/// mTLS negative space: `required` must abort the handshake for a client +/// that presents no certificate, and the request must never reach the +/// backend. +fn try_mtls_required_rejects_client_without_cert() -> State { + let (response, requests_received) = run_mtls_handshake( + "TLS-MTLS-REQUIRED-REJECT", + ClientAuthMode::ClientAuthRequired, + ClientIdentity::None, + ); + + println!( + "response={:?} requests_received={requests_received}", + response.as_ref().map(|r| String::from_utf8_lossy(r)) + ); + + // No response AND an untouched backend. Asserting only on the absence of + // a response would also pass if Sozu answered a 4xx after admitting the + // session, which is not what `required` means. + if response.is_none() && requests_received == 0 { + State::Success + } else { + State::Fail + } +} + +#[test] +fn test_mtls_required_rejects_client_without_cert() { + assert_eq!( + repeat_until_error_or( + 5, + "TLS mTLS: client_auth=required rejects a client presenting no certificate", + try_mtls_required_rejects_client_without_cert, + ), + State::Success, + ); +} + +/// mTLS positive space: `required` accepts a certificate chaining to the +/// listener's trusted CA, and the request reaches the backend. +fn try_mtls_required_accepts_trusted_client_cert() -> State { + let (response, requests_received) = run_mtls_handshake( + "TLS-MTLS-REQUIRED-OK", + ClientAuthMode::ClientAuthRequired, + ClientIdentity::Trusted, + ); + + let responded = response + .as_ref() + .is_some_and(|r| r.starts_with(b"HTTP/1.1 200")); + println!( + "responded={responded} requests_received={requests_received} response={:?}", + response.as_ref().map(|r| String::from_utf8_lossy(r)) + ); + + if responded && requests_received == 1 { + State::Success + } else { + State::Fail + } +} + +#[test] +fn test_mtls_required_accepts_trusted_client_cert() { + assert_eq!( + repeat_until_error_or( + 5, + "TLS mTLS: client_auth=required accepts a certificate chaining to the trusted CA", + try_mtls_required_accepts_trusted_client_cert, + ), + State::Success, + ); +} + +/// mTLS `optional`: a client presenting no certificate is admitted. This is +/// the branch that `allow_unauthenticated()` controls — if it were applied to +/// the `required` arm instead, this test would still pass while +/// `test_mtls_required_rejects_client_without_cert` above would fail, which is +/// how the pair pins the mode boundary. +fn try_mtls_optional_accepts_client_without_cert() -> State { + let (response, requests_received) = run_mtls_handshake( + "TLS-MTLS-OPTIONAL-OK", + ClientAuthMode::ClientAuthOptional, + ClientIdentity::None, + ); + + let responded = response + .as_ref() + .is_some_and(|r| r.starts_with(b"HTTP/1.1 200")); + println!( + "responded={responded} requests_received={requests_received} response={:?}", + response.as_ref().map(|r| String::from_utf8_lossy(r)) + ); + + if responded && requests_received == 1 { + State::Success + } else { + State::Fail + } +} + +#[test] +fn test_mtls_optional_accepts_client_without_cert() { + assert_eq!( + repeat_until_error_or( + 5, + "TLS mTLS: client_auth=optional admits a client presenting no certificate", + try_mtls_optional_accepts_client_without_cert, + ), + State::Success, + ); +} diff --git a/lib/src/https.rs b/lib/src/https.rs index c9cf6caa4..5f5be7294 100644 --- a/lib/src/https.rs +++ b/lib/src/https.rs @@ -27,17 +27,20 @@ use mio::{ unix::SourceFd, }; use rustls::{ - CipherSuite, ProtocolVersion, ServerConfig as RustlsServerConfig, ServerConnection, - SupportedCipherSuite, crypto::CryptoProvider, + CipherSuite, ProtocolVersion, RootCertStore, ServerConfig as RustlsServerConfig, + ServerConnection, SupportedCipherSuite, + crypto::CryptoProvider, + pki_types::{CertificateDer, CertificateRevocationListDer, pem::PemObject}, + server::WebPkiClientVerifier, }; use rusty_ulid::Ulid; use sozu_command::{ certificate::Fingerprint, config::{DEFAULT_ALPN_PROTOCOLS, DEFAULT_CIPHER_LIST}, proto::command::{ - AddCertificate, CertificateSummary, CertificatesByAddress, Cluster, HttpsListenerConfig, - ListOfCertificatesByAddress, ListenerType, RemoveCertificate, RemoveListener, - ReplaceCertificate, RequestHttpFrontend, ResponseContent, TlsVersion, + AddCertificate, CertificateSummary, CertificatesByAddress, ClientAuthMode, Cluster, + HttpsListenerConfig, ListOfCertificatesByAddress, ListenerType, RemoveCertificate, + RemoveListener, ReplaceCertificate, RequestHttpFrontend, ResponseContent, TlsVersion, UpdateHttpsListenerConfig, WorkerRequest, WorkerResponse, request::RequestType, response_content::ContentType, }, @@ -1481,17 +1484,31 @@ impl HttpsListener { .collect::>() }; - let provider = CryptoProvider { + let provider = Arc::new(CryptoProvider { cipher_suites: ciphers, kx_groups, ..default_provider() - }; + }); - let mut server_config = RustlsServerConfig::builder_with_provider(provider.into()) + let builder = RustlsServerConfig::builder_with_provider(provider.clone()) .with_protocol_versions(&versions[..]) - .map_err(|err| ListenerError::BuildRustls(err.to_string()))? - .with_no_client_auth() - .with_cert_resolver(resolver); + .map_err(|err| ListenerError::BuildRustls(err.to_string()))?; + + // mTLS: `client_auth` decodes to `ClientAuthMode` (NONE at field 0). + // NONE keeps the historical `.with_no_client_auth()`; OPTIONAL and + // REQUIRED install a WebPki client-certificate verifier built from the + // listener's trusted CA bundle (and optional CRLs). OPTIONAL differs + // only by allowing connections that present no certificate at all. The + // verifier is built from the same `provider` as the server config so it + // works in single-provider builds (`crypto-openssl`-only) and in + // multi-provider builds (`--all-features`) where no process-default + // provider is installed. + let builder = match Self::client_cert_verifier(config, &provider)? { + Some(verifier) => builder.with_client_cert_verifier(verifier), + None => builder.with_no_client_auth(), + }; + + let mut server_config = builder.with_cert_resolver(resolver); server_config.send_tls13_tickets = config.send_tls13_tickets as usize; server_config.alpn_protocols = if config.alpn_protocols.is_empty() { @@ -1510,6 +1527,101 @@ impl HttpsListener { Ok(server_config) } + /// Build the rustls client-certificate verifier for a listener's mTLS + /// configuration, or `None` when client auth is disabled. + /// + /// Returns `None` only for [`ClientAuthMode::ClientAuthNone`] (and for an + /// absent `client_auth` field, which decodes to that variant), preserving + /// the historical `.with_no_client_auth()` path. For OPTIONAL/REQUIRED it + /// parses `client_ca_certificates` into a root store and, when present, + /// `client_ca_crls` into revocation lists. OPTIONAL additionally allows + /// unauthenticated clients (no certificate presented); REQUIRED aborts the + /// handshake in that case. + /// + /// The verifier is built with the caller's `provider` so it never relies on + /// a process-default crypto provider (which may be absent or ambiguous). An + /// unknown `client_auth` value is rejected rather than treated as NONE, so a + /// malformed or future enum value can never silently disable client auth. + fn client_cert_verifier( + config: &HttpsListenerConfig, + provider: &Arc, + ) -> Result>, ListenerError> { + // Reject unknown enum values instead of failing open to NONE. + let raw = config + .client_auth + .unwrap_or(ClientAuthMode::ClientAuthNone as i32); + let mode = ClientAuthMode::try_from(raw).map_err(|_| { + ListenerError::ClientAuth(format!("unknown client_auth mode value {raw}")) + })?; + if mode == ClientAuthMode::ClientAuthNone { + return Ok(None); + } + + // Parse every trusted-CA PEM entry into the root store. Each entry may + // itself hold a concatenated PEM chain, so iterate per entry. An entry + // that contributes no certificate (empty text, or PEM carrying another + // section type) is rejected rather than skipped: it would start the + // listener with only a subset of the configured trust anchors, so + // clients issued by the omitted CA would fail with no visible cause. + let mut roots = RootCertStore::empty(); + for pem in &config.client_ca_certificates { + let before = roots.len(); + for cert in CertificateDer::pem_slice_iter(pem.as_bytes()) { + let cert = + cert.map_err(|e| ListenerError::ClientAuth(format!("invalid CA PEM: {e}")))?; + roots + .add(cert) + .map_err(|e| ListenerError::ClientAuth(format!("untrusted CA: {e}")))?; + } + if roots.len() == before { + return Err(ListenerError::ClientAuth( + "a configured trusted-CA entry contained no certificate".to_string(), + )); + } + } + if roots.is_empty() { + return Err(ListenerError::ClientAuth( + "client auth requested but no trusted CA certificate was provided".to_string(), + )); + } + + // Parse CRLs per configured entry. An entry that yields zero CRLs (empty + // text, or PEM carrying another section type) is a misconfiguration: it + // would silently disable revocation, so reject it rather than build a + // verifier that skips `with_crls`. + let mut crls = Vec::new(); + for pem in &config.client_ca_crls { + let before = crls.len(); + for crl in CertificateRevocationListDer::pem_slice_iter(pem.as_bytes()) { + crls.push( + crl.map_err(|e| ListenerError::ClientAuth(format!("invalid CRL PEM: {e}")))?, + ); + } + if crls.len() == before { + return Err(ListenerError::ClientAuth( + "a configured CRL entry contained no certificate revocation list".to_string(), + )); + } + } + + let mut builder = + WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider.clone()); + if !crls.is_empty() { + // rustls defaults to `ExpirationPolicy::Ignore`, which keeps trusting + // a CRL past its `nextUpdate`. Enforce expiration so a stale CRL is + // rejected rather than silently accepting certificates whose + // revocation status is no longer known. + builder = builder.with_crls(crls).enforce_revocation_expiration(); + } + if mode == ClientAuthMode::ClientAuthOptional { + builder = builder.allow_unauthenticated(); + } + let verifier = builder + .build() + .map_err(|e| ListenerError::ClientAuth(e.to_string()))?; + Ok(Some(verifier)) + } + /// Apply a partial-update patch to this listener's live configuration. /// /// Fields absent in the patch (i.e. `None`) are preserved unchanged. @@ -3125,4 +3237,145 @@ mod tests { Duration::from_secs(1) ); } + + /// A self-signed cert usable as a CA root anchor in the verifier tests. + const TEST_CA_PEM: &str = include_str!("../assets/certificate.pem"); + + fn test_crypto_provider() -> Arc { + Arc::new(default_provider()) + } + + fn https_config_with_client_auth( + mode: ClientAuthMode, + cas: &[&str], + crls: &[&str], + ) -> HttpsListenerConfig { + let address = SocketAddress::new_v4(127, 0, 0, 1, 1049); + let mut config = ListenerBuilder::new_https(address) + .to_tls(None) + .expect("default HTTPS listener config"); + config.client_auth = Some(mode as i32); + config.client_ca_certificates = cas.iter().map(|s| s.to_string()).collect(); + config.client_ca_crls = crls.iter().map(|s| s.to_string()).collect(); + config + } + + #[test] + fn client_cert_verifier_none_is_disabled() { + // NONE (and an absent field) must keep the no-client-auth path. + let provider = test_crypto_provider(); + let config = https_config_with_client_auth(ClientAuthMode::ClientAuthNone, &[], &[]); + assert!( + HttpsListener::client_cert_verifier(&config, &provider) + .expect("verifier build") + .is_none() + ); + + let mut absent = config; + absent.client_auth = None; + assert!( + HttpsListener::client_cert_verifier(&absent, &provider) + .expect("verifier build") + .is_none(), + "absent client_auth must decode to NONE" + ); + } + + #[test] + fn client_cert_verifier_rejects_unknown_mode() { + // An unknown (future/garbage) enum value must be rejected, never fold + // to NONE and silently accept unauthenticated clients. + let provider = test_crypto_provider(); + let mut config = + https_config_with_client_auth(ClientAuthMode::ClientAuthRequired, &[TEST_CA_PEM], &[]); + config.client_auth = Some(999); + assert!(matches!( + HttpsListener::client_cert_verifier(&config, &provider), + Err(ListenerError::ClientAuth(_)) + )); + } + + #[test] + fn client_cert_verifier_required_builds_with_ca() { + let provider = test_crypto_provider(); + let config = + https_config_with_client_auth(ClientAuthMode::ClientAuthRequired, &[TEST_CA_PEM], &[]); + assert!( + HttpsListener::client_cert_verifier(&config, &provider) + .expect("verifier build") + .is_some() + ); + } + + #[test] + fn client_cert_verifier_optional_builds_with_ca() { + let provider = test_crypto_provider(); + let config = + https_config_with_client_auth(ClientAuthMode::ClientAuthOptional, &[TEST_CA_PEM], &[]); + assert!( + HttpsListener::client_cert_verifier(&config, &provider) + .expect("verifier build") + .is_some() + ); + } + + #[test] + fn client_cert_verifier_requires_a_trusted_ca() { + // A non-NONE mode with an empty CA bundle is a configuration error, not + // a silently-permissive listener. + let provider = test_crypto_provider(); + let config = https_config_with_client_auth(ClientAuthMode::ClientAuthRequired, &[], &[]); + assert!(matches!( + HttpsListener::client_cert_verifier(&config, &provider), + Err(ListenerError::ClientAuth(_)) + )); + } + + #[test] + fn client_cert_verifier_rejects_malformed_ca() { + let provider = test_crypto_provider(); + let config = https_config_with_client_auth( + ClientAuthMode::ClientAuthRequired, + &["not a pem certificate"], + &[], + ); + // A CA entry that parses to zero anchors is a configuration error. + assert!(matches!( + HttpsListener::client_cert_verifier(&config, &provider), + Err(ListenerError::ClientAuth(_)) + )); + } + + #[test] + fn client_cert_verifier_rejects_empty_ca_entry_among_valid_ones() { + // A valid CA followed by an entry contributing no certificate must be + // rejected, not silently dropped, so the configured trust set is never + // quietly reduced to a subset. + let provider = test_crypto_provider(); + let config = https_config_with_client_auth( + ClientAuthMode::ClientAuthRequired, + &[TEST_CA_PEM, "not a certificate"], + &[], + ); + assert!(matches!( + HttpsListener::client_cert_verifier(&config, &provider), + Err(ListenerError::ClientAuth(_)) + )); + } + + #[test] + fn client_cert_verifier_rejects_empty_crl_entry() { + // A configured CRL entry that yields zero CRLs would silently disable + // revocation; it must be rejected rather than skipped. + let provider = test_crypto_provider(); + let config = https_config_with_client_auth( + ClientAuthMode::ClientAuthRequired, + &[TEST_CA_PEM], + &["not a crl"], + ); + assert!(matches!( + HttpsListener::client_cert_verifier(&config, &provider), + Err(ListenerError::ClientAuth(_)) + )); + } } diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 47b0df8b8..3e699be08 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -773,6 +773,8 @@ pub enum ListenerError { TemplateParse(String, TemplateError), #[error("failed to build rustls context, {0}")] BuildRustls(String), + #[error("failed to configure client certificate authentication (mTLS): {0}")] + ClientAuth(String), #[error("could not activate listener with address {address:?}: {error}")] Activation { address: SocketAddr, error: String }, #[error("Could not register listener socket: {0}")]