From 7705f673bf8374d5616a54cc7bc685538269d288 Mon Sep 17 00:00:00 2001 From: Adam Wick Date: Thu, 3 Aug 2023 18:10:24 +0000 Subject: [PATCH] Support mTLS for dynamic backends. --- Cargo.lock | 1 + Cargo.toml | 4 ++ cli/tests/integration/common/backends.rs | 1 + cli/tests/trap-test/Cargo.lock | 1 + lib/Cargo.toml | 1 + lib/compute-at-edge-abi/typenames.witx | 2 +- lib/src/config.rs | 2 +- lib/src/config/backends.rs | 7 +++ lib/src/config/backends/client_cert_info.rs | 65 +++++++++++++++++++++ lib/src/error.rs | 5 +- lib/src/upstream.rs | 63 ++++++++++---------- lib/src/wiggle_abi/req_impl.rs | 40 +++++++++++++ 12 files changed, 156 insertions(+), 36 deletions(-) create mode 100644 lib/src/config/backends/client_cert_info.rs diff --git a/Cargo.lock b/Cargo.lock index c9a656e9..eea2ad63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2377,6 +2377,7 @@ dependencies = [ "regex", "rustls", "rustls-native-certs", + "rustls-pemfile", "semver 0.10.0", "serde", "serde_derive", diff --git a/Cargo.toml b/Cargo.toml index 9d99e006..604c77c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,10 +22,14 @@ opt-level = 1 [workspace.dependencies] anyhow = "1.0.31" +base64 = "0.21.2" hyper = { version = "=0.14.26", features = ["full"] } itertools = "0.10.5" +rustls = { version = "0.21.5", features = ["dangerous_configuration"] } +rustls-pemfile = "1.0.3" serde_json = "1.0.59" tokio = { version = "1.21.2", features = ["full"] } +tokio-rustls = "0.24.1" tracing = "0.1.37" tracing-futures = "0.2.5" futures = "0.3.24" diff --git a/cli/tests/integration/common/backends.rs b/cli/tests/integration/common/backends.rs index f539c3f2..68b99e75 100644 --- a/cli/tests/integration/common/backends.rs +++ b/cli/tests/integration/common/backends.rs @@ -73,6 +73,7 @@ impl TestBackends { override_host: backend.override_host.clone(), cert_host: backend.cert_host.clone(), use_sni: backend.use_sni, + client_cert: None, }; backends.insert(name.to_string(), Arc::new(backend_config)); } diff --git a/cli/tests/trap-test/Cargo.lock b/cli/tests/trap-test/Cargo.lock index 1325fa15..e2d77e4d 100644 --- a/cli/tests/trap-test/Cargo.lock +++ b/cli/tests/trap-test/Cargo.lock @@ -2294,6 +2294,7 @@ dependencies = [ "regex", "rustls", "rustls-native-certs", + "rustls-pemfile", "semver 0.10.0", "serde", "serde_derive", diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 392a4f00..ca9f7c6a 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -39,6 +39,7 @@ lazy_static = "^1.4.0" regex = "^1.3.9" rustls = "^0.21.1" rustls-native-certs = "^0.6.3" +rustls-pemfile = "^1.0.3" semver = "^0.10.0" serde = "^1.0.145" serde_derive = "^1.0.114" diff --git a/lib/compute-at-edge-abi/typenames.witx b/lib/compute-at-edge-abi/typenames.witx index d045aaaa..71a53f13 100644 --- a/lib/compute-at-edge-abi/typenames.witx +++ b/lib/compute-at-edge-abi/typenames.witx @@ -183,7 +183,7 @@ $ciphers $sni_hostname $dont_pool - $client_cer + $client_cert )) (typename $dynamic_backend_config diff --git a/lib/src/config.rs b/lib/src/config.rs index 25ec92bd..82a8cc38 100644 --- a/lib/src/config.rs +++ b/lib/src/config.rs @@ -29,7 +29,7 @@ pub type Dictionaries = HashMap; /// Types and deserializers for backend configuration settings. mod backends; -pub use self::backends::Backend; +pub use self::backends::{Backend, ClientCertError, ClientCertInfo}; pub type Backends = HashMap>; diff --git a/lib/src/config/backends.rs b/lib/src/config/backends.rs index a8c13ed8..f52bb156 100644 --- a/lib/src/config/backends.rs +++ b/lib/src/config/backends.rs @@ -1,8 +1,12 @@ +mod client_cert_info; + use { hyper::{header::HeaderValue, Uri}, std::{collections::HashMap, sync::Arc}, }; +pub use self::client_cert_info::{ClientCertError, ClientCertInfo}; + /// A single backend definition. #[derive(Clone, Debug)] pub struct Backend { @@ -10,6 +14,7 @@ pub struct Backend { pub override_host: Option, pub cert_host: Option, pub use_sni: bool, + pub client_cert: Option, } /// A map of [`Backend`] definitions, keyed by their name. @@ -128,6 +133,8 @@ mod deserialization { override_host, cert_host, use_sni, + // NOTE: Update when we support client certs in static backends + client_cert: None, }) } } diff --git a/lib/src/config/backends/client_cert_info.rs b/lib/src/config/backends/client_cert_info.rs new file mode 100644 index 00000000..3a340b9c --- /dev/null +++ b/lib/src/config/backends/client_cert_info.rs @@ -0,0 +1,65 @@ +use rustls::{Certificate, PrivateKey}; +use std::fmt; +use std::io::Cursor; + +#[derive(Clone)] +pub struct ClientCertInfo { + certificates: Vec, + key: PrivateKey, +} + +impl fmt::Debug for ClientCertInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.certs().fmt(f) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ClientCertError { + #[error("Certificate/key read error: {0}")] + CertificateRead(#[from] std::io::Error), + #[error("No keys found for client certificate")] + NoKeysFound, + #[error("Too many keys found for client certificate (found {0})")] + TooManyKeys(usize), +} + +impl ClientCertInfo { + pub fn new(certificate_bytes: &[u8], certificate_key: &[u8]) -> Result { + let mut certificate_bytes_reader = Cursor::new(certificate_bytes); + let mut key_bytes_reader = Cursor::new(certificate_key); + let cert_info = rustls_pemfile::read_all(&mut certificate_bytes_reader)?; + let key_info = rustls_pemfile::read_all(&mut key_bytes_reader)?; + + let mut certificates = Vec::new(); + let mut keys = Vec::new(); + + for item in cert_info.into_iter().chain(key_info) { + match item { + rustls_pemfile::Item::X509Certificate(x) => certificates.push(Certificate(x)), + rustls_pemfile::Item::RSAKey(x) => keys.push(PrivateKey(x)), + rustls_pemfile::Item::PKCS8Key(x) => keys.push(PrivateKey(x)), + rustls_pemfile::Item::ECKey(x) => keys.push(PrivateKey(x)), + _ => {} + } + } + + let key = if keys.is_empty() { + return Err(ClientCertError::NoKeysFound); + } else if keys.len() > 1 { + return Err(ClientCertError::TooManyKeys(keys.len())); + } else { + keys.remove(0) + }; + + Ok(ClientCertInfo { certificates, key }) + } + + pub fn certs(&self) -> Vec { + self.certificates.clone() + } + + pub fn key(&self) -> PrivateKey { + self.key.clone() + } +} diff --git a/lib/src/error.rs b/lib/src/error.rs index 712d981e..baaec6c1 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -135,6 +135,9 @@ pub enum Error { #[error("String conversion error")] ToStr(#[from] http::header::ToStrError), + + #[error("invalid client certificate")] + InvalidClientCert(#[from] crate::config::ClientCertError), } impl Error { @@ -152,7 +155,7 @@ impl Error { Error::Unsupported { .. } => FastlyStatus::Unsupported, Error::HandleError { .. } => FastlyStatus::Badf, Error::InvalidStatusCode { .. } => FastlyStatus::Inval, - Error::UnknownBackend(_) => FastlyStatus::Inval, + Error::UnknownBackend(_) | Error::InvalidClientCert(_) => FastlyStatus::Inval, // Map specific kinds of `hyper::Error` into their respective error codes. Error::HyperError(e) if e.is_parse() => FastlyStatus::Httpinvalid, Error::HyperError(e) if e.is_user() => FastlyStatus::Httpuser, diff --git a/lib/src/upstream.rs b/lib/src/upstream.rs index 4b2f5aff..58e5404f 100644 --- a/lib/src/upstream.rs +++ b/lib/src/upstream.rs @@ -9,7 +9,7 @@ use crate::{ use futures::Future; use http::{uri, HeaderValue}; use hyper::{client::HttpConnector, header, Client, HeaderMap, Request, Response, Uri}; -use rustls::client::ServerName; +use rustls::client::{ServerName, WantsTransparencyPolicyOrClientCert}; use std::{ io, pin::Pin, @@ -31,43 +31,36 @@ static GZIP_VALUES: [HeaderValue; 2] = [ /// Viceroy's preloaded TLS configuration. /// -/// Setting up client configuration is meant to be done once per process. However, we need -/// two distinct configurations, because backends may choose whether to employ SNI, and that -/// setting is baked into the configuration data. +/// We now have too many options to fully precompute this value, so what this actually +/// holds is a partially-complete TLS config builder, waiting for the point at which +/// we decide whether or not to provide a client certificate and whether or not to use +/// SNI. #[derive(Clone)] pub struct TlsConfig { - with_sni: Arc, - without_sni: Arc, + partial_config: + rustls::ConfigBuilder, } -fn setup_rustls(with_sni: bool) -> Result { - let mut roots = rustls::RootCertStore::empty(); - match rustls_native_certs::load_native_certs() { - Ok(certs) => { - for cert in certs { - roots.add(&rustls::Certificate(cert.0)).unwrap(); +impl TlsConfig { + pub fn new() -> Result { + let mut roots = rustls::RootCertStore::empty(); + match rustls_native_certs::load_native_certs() { + Ok(certs) => { + for cert in certs { + roots.add(&rustls::Certificate(cert.0)).unwrap(); + } } + Err(err) => return Err(Error::BadCerts(err)), + } + if roots.is_empty() { + warn!("no CA certificates available"); } - Err(err) => return Err(Error::BadCerts(err)), - } - if roots.is_empty() { - warn!("no CA certificates available"); - } - let mut config = rustls::ClientConfig::builder() - .with_safe_defaults() - .with_root_certificates(roots) - .with_no_client_auth(); - config.enable_sni = with_sni; - Ok(config) -} + let partial_config = rustls::ClientConfig::builder() + .with_safe_defaults() + .with_root_certificates(roots); -impl TlsConfig { - pub fn new() -> Result { - Ok(TlsConfig { - with_sni: Arc::new(setup_rustls(true)?), - without_sni: Arc::new(setup_rustls(false)?), - }) + Ok(TlsConfig { partial_config }) } } @@ -127,11 +120,15 @@ impl hyper::service::Service for BackendConnector { let tcp = connect_fut.await.map_err(Box::new)?; if backend.uri.scheme_str() == Some("https") { - let connector = if backend.use_sni { - TlsConnector::from(config.with_sni.clone()) + let mut config = if let Some(certed_key) = &backend.client_cert { + config + .partial_config + .with_client_auth_cert(certed_key.certs(), certed_key.key())? } else { - TlsConnector::from(config.without_sni.clone()) + config.partial_config.with_no_client_auth() }; + config.enable_sni = backend.use_sni; + let connector = TlsConnector::from(Arc::new(config)); let cert_host = backend .cert_host diff --git a/lib/src/wiggle_abi/req_impl.rs b/lib/src/wiggle_abi/req_impl.rs index bcd51418..173680be 100644 --- a/lib/src/wiggle_abi/req_impl.rs +++ b/lib/src/wiggle_abi/req_impl.rs @@ -1,5 +1,9 @@ //! fastly_req` hostcall implementations. +use super::SecretStoreError; +use crate::config::ClientCertInfo; +use crate::secret_store::SecretLookup; + use { crate::{ config::Backend, @@ -310,6 +314,41 @@ impl FastlyHttpReq for Session { true }; + let client_cert = if backend_info_mask.contains(BackendConfigOptions::CLIENT_CERT) { + let cert_slice = config + .client_certificate + .as_array(config.client_certificate_len) + .as_slice()? + .ok_or(Error::SharedMemory)?; + let key_lookup = + self.secret_lookup(config.client_key) + .ok_or(Error::SecretStoreError( + SecretStoreError::InvalidSecretHandle(config.client_key), + ))?; + let key = match &key_lookup { + SecretLookup::Standard { + store_name, + secret_name, + } => self + .secret_stores() + .get_store(store_name) + .ok_or(Error::SecretStoreError( + SecretStoreError::InvalidSecretHandle(config.client_key), + ))? + .get_secret(secret_name) + .ok_or(Error::SecretStoreError( + SecretStoreError::InvalidSecretHandle(config.client_key), + ))? + .plaintext(), + + SecretLookup::Injected { plaintext } => plaintext, + }; + + Some(ClientCertInfo::new(&cert_slice, key)?) + } else { + None + }; + let new_backend = Backend { uri: Uri::builder() .scheme(scheme) @@ -319,6 +358,7 @@ impl FastlyHttpReq for Session { override_host, cert_host, use_sni, + client_cert, }; if !self.add_backend(name, new_backend) {