diff --git a/core/http/src/hyper.rs b/core/http/src/hyper.rs index 1b148fc5..aadd840a 100644 --- a/core/http/src/hyper.rs +++ b/core/http/src/hyper.rs @@ -4,20 +4,8 @@ //! These types will, with certainty, be removed with time, but they reside here //! while necessary. -#[doc(hidden)] pub use hyper::{Body, Error, Request, Response, Version}; -#[doc(hidden)] pub use hyper::body::{Bytes, HttpBody, Sender as BodySender}; -#[doc(hidden)] pub use hyper::rt::Executor; -#[doc(hidden)] pub use hyper::server::Server; -#[doc(hidden)] pub use hyper::service::{make_service_fn, service_fn, Service}; - -#[doc(hidden)] pub use http::header::HeaderMap; -#[doc(hidden)] pub use http::header::HeaderName as HeaderName; -#[doc(hidden)] pub use http::header::HeaderValue as HeaderValue; -#[doc(hidden)] pub use http::method::Method; -#[doc(hidden)] pub use http::request::Parts as RequestParts; -#[doc(hidden)] pub use http::response::Builder as ResponseBuilder; -#[doc(hidden)] pub use http::status::StatusCode; -#[doc(hidden)] pub use http::uri::{Uri, Parts as UriParts}; +#[doc(hidden)] pub use hyper::*; +#[doc(hidden)] pub use http::*; /// Reexported http header types. pub mod header { diff --git a/core/http/src/tls/mtls.rs b/core/http/src/tls/mtls.rs index 9f712d22..d588ef3a 100644 --- a/core/http/src/tls/mtls.rs +++ b/core/http/src/tls/mtls.rs @@ -89,6 +89,98 @@ pub enum Error { Trailing(usize), } +/// A request guard for validated, verified client certificates. +/// +/// This type is a wrapper over [`x509::TbsCertificate`] with convenient +/// methods and complete documentation. Should the data exposed by the inherent +/// methods not suffice, this type derefs to [`x509::TbsCertificate`]. +/// +/// # Request Guard +/// +/// The request guard implementation succeeds if: +/// +/// * The client presents certificates. +/// * The certificates are active and not yet expired. +/// * The client's certificate chain was signed by the CA identified by the +/// configured `ca_certs` and with respect to SNI, if any. See [module level +/// docs](self) for configuration details. +/// +/// If the client does not present certificates, the guard _forwards_. +/// +/// If the certificate chain fails to validate or verify, the guard _fails_ with +/// the respective [`Error`]. +/// +/// # Wrapping +/// +/// To implement roles, the `Certificate` guard can be wrapped with a more +/// semantically meaningful type with extra validation. For example, if a +/// certificate with a specific serial number is known to belong to an +/// administrator, a `CertifiedAdmin` type can authorize as follow: +/// +/// ```rust +/// # #[macro_use] extern crate rocket; +/// use rocket::mtls::{self, bigint::BigUint, Certificate}; +/// use rocket::request::{Request, FromRequest, Outcome}; +/// use rocket::outcome::try_outcome; +/// +/// // The serial number for the certificate issued to the admin. +/// const ADMIN_SERIAL: &str = "65828378108300243895479600452308786010218223563"; +/// +/// // A request guard that authenticates and authorizes an administrator. +/// struct CertifiedAdmin<'r>(Certificate<'r>); +/// +/// #[rocket::async_trait] +/// impl<'r> FromRequest<'r> for CertifiedAdmin<'r> { +/// type Error = mtls::Error; +/// +/// async fn from_request(req: &'r Request<'_>) -> Outcome { +/// let cert = try_outcome!(req.guard::>().await); +/// if let Some(true) = cert.has_serial(ADMIN_SERIAL) { +/// Outcome::Success(CertifiedAdmin(cert)) +/// } else { +/// Outcome::Forward(()) +/// } +/// } +/// } +/// +/// #[get("/admin")] +/// fn admin(admin: CertifiedAdmin<'_>) { +/// // This handler can only execute if an admin is authenticated. +/// } +/// +/// #[get("/admin", rank = 2)] +/// fn unauthorized(user: Option>) { +/// // This handler always executes, whether there's a non-admin user that's +/// // authenticated (user = Some()) or not (user = None). +/// } +/// ``` +/// +/// # Example +/// +/// To retrieve certificate data in a route, use `Certificate` as a guard: +/// +/// ```rust +/// # extern crate rocket; +/// # use rocket::get; +/// use rocket::mtls::{self, Certificate}; +/// +/// #[get("/auth")] +/// fn auth(cert: Certificate<'_>) { +/// // This handler only runs when a valid certificate was presented. +/// } +/// +/// #[get("/maybe")] +/// fn maybe_auth(cert: Option>) { +/// // This handler runs even if no certificate was presented or an invalid +/// // certificate was presented. +/// } +/// +/// #[get("/ok")] +/// fn ok_auth(cert: mtls::Result>) { +/// // This handler does not run if a certificate was not presented but +/// // _does_ run if a valid (Ok) or invalid (Err) one was presented. +/// } +/// ``` #[repr(transparent)] #[derive(Debug, PartialEq)] pub struct Certificate<'a>(X509Certificate<'a>); @@ -138,26 +230,136 @@ impl<'a> Certificate<'a> { } } + /// Returns the serial number of the X.509 certificate. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// # use rocket::get; + /// use rocket::mtls::Certificate; + /// + /// #[get("/auth")] + /// fn auth(cert: Certificate<'_>) { + /// let cert = cert.serial(); + /// } + /// ``` pub fn serial(&self) -> &bigint::BigUint { &self.inner().serial } + /// Returns the version of the X.509 certificate. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// # use rocket::get; + /// use rocket::mtls::Certificate; + /// + /// #[get("/auth")] + /// fn auth(cert: Certificate<'_>) { + /// let cert = cert.version(); + /// } + /// ``` pub fn version(&self) -> u32 { self.inner().version.0 } + /// Returns the subject (a "DN" or "Distinguised Name") of the X.509 + /// certificate. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// # use rocket::get; + /// use rocket::mtls::Certificate; + /// + /// #[get("/auth")] + /// fn auth(cert: Certificate<'_>) { + /// if let Some(name) = cert.subject().common_name() { + /// println!("Hello, {}!", name); + /// } + /// } + /// ``` pub fn subject(&self) -> &Name<'a> { Name::ref_cast(&self.inner().subject) } + /// Returns the issuer (a "DN" or "Distinguised Name") of the X.509 + /// certificate. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// # use rocket::get; + /// use rocket::mtls::Certificate; + /// + /// #[get("/auth")] + /// fn auth(cert: Certificate<'_>) { + /// if let Some(name) = cert.issuer().common_name() { + /// println!("Issued by: {}", name); + /// } + /// } + /// ``` pub fn issuer(&self) -> &Name<'a> { Name::ref_cast(&self.inner().issuer) } + /// Returns a map of the extensions in the X.509 certificate. + /// + /// # Example + /// + /// ```rust + /// # extern crate rocket; + /// # use rocket::get; + /// use rocket::mtls::{oid, x509, Certificate}; + /// + /// #[get("/auth")] + /// fn auth(cert: Certificate<'_>) { + /// let subject_alt = cert.extensions() + /// .get(&oid::OID_X509_EXT_SUBJECT_ALT_NAME) + /// .and_then(|e| match e.parsed_extension() { + /// x509::ParsedExtension::SubjectAlternativeName(s) => Some(s), + /// _ => None + /// }); + /// + /// if let Some(subject_alt) = subject_alt { + /// for name in &subject_alt.general_names { + /// if let x509::GeneralName::RFC822Name(name) = name { + /// println!("An email, perhaps? {}", name); + /// } + /// } + /// } + /// } + /// ``` pub fn extensions(&self) -> &HashMap, x509::X509Extension<'a>> { &self.inner().extensions } + /// Checks if the certificate has the serial number `number`. + /// + /// If `number` is not a valid unsigned integer in base 10, returns `None`. + /// + /// Otherwise, returns `Some(true)` if it does and `Some(false)` if it does + /// not. + /// + /// ```rust + /// # extern crate rocket; + /// # use rocket::get; + /// use rocket::mtls::Certificate; + /// + /// const SERIAL: &str = "65828378108300243895479600452308786010218223563"; + /// + /// #[get("/auth")] + /// fn auth(cert: Certificate<'_>) { + /// if cert.has_serial(SERIAL).unwrap_or(false) { + /// println!("certificate has the expected serial number"); + /// } + /// } + /// ``` pub fn has_serial(&self, number: &str) -> Option { let uint: bigint::BigUint = number.parse().ok()?; Some(&uint == self.serial()) @@ -173,22 +375,116 @@ impl<'a> Deref for Certificate<'a> { } impl<'a> Name<'a> { + /// Returns the _first_ UTF-8 _string_ common name, if any. + /// + /// Note that common names need not be UTF-8 strings, or strings at all. + /// This method returns the first common name attribute that is. + /// + /// # Example + /// + /// ```rust + /// # #[macro_use] extern crate rocket; + /// use rocket::mtls::Certificate; + /// + /// #[get("/auth")] + /// fn auth(cert: Certificate<'_>) { + /// if let Some(name) = cert.subject().common_name() { + /// println!("Hello, {}!", name); + /// } + /// } + /// ``` pub fn common_name(&self) -> Option<&'a str> { self.common_names().next() } + /// Returns an iterator over all of the UTF-8 _string_ common names in + /// `self`. + /// + /// Note that common names need not be UTF-8 strings, or strings at all. + /// This method filters the common names in `self` to those that are. Use + /// the raw [`iter_common_name()`](#method.iter_common_name) to iterate over + /// all value types. + /// + /// # Example + /// + /// ```rust + /// # #[macro_use] extern crate rocket; + /// use rocket::mtls::Certificate; + /// + /// #[get("/auth")] + /// fn auth(cert: Certificate<'_>) { + /// for name in cert.issuer().common_names() { + /// println!("Issued by {}.", name); + /// } + /// } + /// ``` pub fn common_names(&self) -> impl Iterator + '_ { self.iter_by_oid(&oid::OID_X509_COMMON_NAME).filter_map(|n| n.as_str().ok()) } + /// Returns the _first_ UTF-8 _string_ email address, if any. + /// + /// Note that email addresses need not be UTF-8 strings, or strings at all. + /// This method returns the first email address attribute that is. + /// + /// # Example + /// + /// ```rust + /// # #[macro_use] extern crate rocket; + /// use rocket::mtls::Certificate; + /// + /// #[get("/auth")] + /// fn auth(cert: Certificate<'_>) { + /// if let Some(email) = cert.subject().email() { + /// println!("Hello, {}!", email); + /// } + /// } + /// ``` pub fn email(&self) -> Option<&'a str> { self.emails().next() } + /// Returns an iterator over all of the UTF-8 _string_ email addresses in + /// `self`. + /// + /// Note that email addresses need not be UTF-8 strings, or strings at all. + /// This method filters the email addresss in `self` to those that are. Use + /// the raw [`iter_email()`](#method.iter_email) to iterate over all value + /// types. + /// + /// # Example + /// + /// ```rust + /// # #[macro_use] extern crate rocket; + /// use rocket::mtls::Certificate; + /// + /// #[get("/auth")] + /// fn auth(cert: Certificate<'_>) { + /// for email in cert.subject().emails() { + /// println!("Reach me at: {}", email); + /// } + /// } + /// ``` pub fn emails(&self) -> impl Iterator + '_ { self.iter_by_oid(&oid::OID_PKCS9_EMAIL_ADDRESS).filter_map(|n| n.as_str().ok()) } + /// Returns `true` if `self` has no data. + /// + /// When this is the case for a `subject()`, the subject data can be found + /// in the `subjectAlt` [`extension()`](Certificate::extensions()). + /// + /// # Example + /// + /// ```rust + /// # #[macro_use] extern crate rocket; + /// use rocket::mtls::Certificate; + /// + /// #[get("/auth")] + /// fn auth(cert: Certificate<'_>) { + /// let no_data = cert.subject().is_empty(); + /// } + /// ``` pub fn is_empty(&self) -> bool { self.0.as_raw().is_empty() } diff --git a/core/lib/Cargo.toml b/core/lib/Cargo.toml index aef7cf3e..85c91f26 100644 --- a/core/lib/Cargo.toml +++ b/core/lib/Cargo.toml @@ -21,6 +21,7 @@ all-features = true [features] default = [] tls = ["rocket_http/tls"] +mtls = ["rocket_http/mtls", "tls"] secrets = ["rocket_http/private-cookies"] json = ["serde_json", "tokio/io-util"] msgpack = ["rmp-serde", "tokio/io-util"] diff --git a/core/lib/src/config/config.rs b/core/lib/src/config/config.rs index 9587d3af..bc386d17 100644 --- a/core/lib/src/config/config.rs +++ b/core/lib/src/config/config.rs @@ -70,13 +70,17 @@ pub struct Config { pub port: u16, /// Number of threads to use for executing futures. **(default: `num_cores`)** pub workers: usize, - /// Keep-alive timeout in seconds; disabled when `0`. **(default: `5`)** - pub keep_alive: u32, - /// Streaming read size limits. **(default: [`Limits::default()`])** - pub limits: Limits, /// How, if at all, to identify the server via the `Server` header. /// **(default: `"Rocket"`)** pub ident: Ident, + /// Streaming read size limits. **(default: [`Limits::default()`])** + pub limits: Limits, + /// Directory to store temporary files in. **(default: + /// [`std::env::temp_dir()`])** + #[serde(serialize_with = "RelativePathBuf::serialize_relative")] + pub temp_dir: RelativePathBuf, + /// Keep-alive timeout in seconds; disabled when `0`. **(default: `5`)** + pub keep_alive: u32, /// The TLS configuration, if any. **(default: `None`)** #[cfg(feature = "tls")] #[cfg_attr(nightly, doc(cfg(feature = "tls")))] @@ -89,14 +93,10 @@ pub struct Config { #[cfg_attr(nightly, doc(cfg(feature = "secrets")))] #[serde(serialize_with = "SecretKey::serialize_zero")] pub secret_key: SecretKey, - /// Directory to store temporary files in. **(default: - /// [`std::env::temp_dir()`])** - #[serde(serialize_with = "RelativePathBuf::serialize_relative")] - pub temp_dir: RelativePathBuf, - /// Max level to log. **(default: _debug_ `normal` / _release_ `critical`)** - pub log_level: LogLevel, /// Graceful shutdown configuration. **(default: [`Shutdown::default()`])** pub shutdown: Shutdown, + /// Max level to log. **(default: _debug_ `normal` / _release_ `critical`)** + pub log_level: LogLevel, /// Whether to use colors and emoji when logging. **(default: `true`)** #[serde(deserialize_with = "figment::util::bool_from_str_or_int")] pub cli_colors: bool, @@ -167,16 +167,16 @@ impl Config { address: Ipv4Addr::new(127, 0, 0, 1).into(), port: 8000, workers: num_cpus::get(), - keep_alive: 5, - limits: Limits::default(), ident: Ident::default(), + limits: Limits::default(), + temp_dir: std::env::temp_dir().into(), + keep_alive: 5, #[cfg(feature = "tls")] tls: None, #[cfg(feature = "secrets")] secret_key: SecretKey::zero(), - temp_dir: std::env::temp_dir().into(), - log_level: LogLevel::Normal, shutdown: Shutdown::default(), + log_level: LogLevel::Normal, cli_colors: true, __non_exhaustive: (), } @@ -316,31 +316,62 @@ impl Config { #[cfg(not(feature = "tls"))] { false } } + /// Returns `true` if mTLS is enabled. + /// + /// mTLS is enabled when TLS is enabled ([`Config::tls_enabled()`]) _and_ + /// the `mtls` feature is enabled _and_ mTLS has been configured with a CA + /// certificate chain. + /// + /// # Example + /// + /// ```rust + /// let config = rocket::Config::default(); + /// if config.mtls_enabled() { + /// println!("mTLS is enabled!"); + /// } else { + /// println!("mTLS is disabled."); + /// } + /// ``` + pub fn mtls_enabled(&self) -> bool { + if !self.tls_enabled() { + return false; + } + + #[cfg(feature = "mtls")] { + self.tls.as_ref().map_or(false, |tls| tls.mutual.is_some()) + } + + #[cfg(not(feature = "mtls"))] { false } + } + pub(crate) fn pretty_print(&self, figment: &Figment) { use crate::log::PaintExt; - launch_info!("{}Configured for {}.", Paint::emoji("🔧 "), self.profile); - - launch_info_!("address: {}", Paint::default(&self.address).bold()); - launch_info_!("port: {}", Paint::default(&self.port).bold()); - launch_info_!("workers: {}", Paint::default(self.workers).bold()); - launch_info_!("ident: {}", Paint::default(&self.ident).bold()); - - let ka = self.keep_alive; - if ka > 0 { - launch_info_!("keep-alive: {}", Paint::default(format!("{}s", ka)).bold()); - } else { - launch_info_!("keep-alive: {}", Paint::default("disabled").bold()); + fn bold(val: T) -> Paint { + Paint::default(val).bold() } - launch_info_!("limits: {}", Paint::default(&self.limits).bold()); - match self.tls_enabled() { - true => launch_info_!("tls: {}", Paint::default("enabled").bold()), - false => launch_info_!("tls: {}", Paint::default("disabled").bold()), + launch_info!("{}Configured for {}.", Paint::emoji("🔧 "), self.profile); + launch_info_!("address: {}", bold(&self.address)); + launch_info_!("port: {}", bold(&self.port)); + launch_info_!("workers: {}", bold(self.workers)); + launch_info_!("ident: {}", bold(&self.ident)); + launch_info_!("limits: {}", bold(&self.limits)); + launch_info_!("temp dir: {}", bold(&self.temp_dir.relative().display())); + + match self.keep_alive { + 0 => launch_info_!("keep-alive: {}", bold("disabled")), + ka => launch_info_!("keep-alive: {}{}", bold(ka), bold("s")), + } + + match (self.tls_enabled(), self.mtls_enabled()) { + (true, true) => launch_info_!("tls: {}", bold("enabled w/mtls")), + (true, false) => launch_info_!("tls: {} w/o mtls", bold("enabled")), + (false, _) => launch_info_!("tls: {}", bold("disabled")), } #[cfg(feature = "secrets")] { - launch_info_!("secret key: {:?}", Paint::default(&self.secret_key).bold()); + launch_info_!("secret key: {}", bold(&self.secret_key)); if !self.secret_key.is_provided() { warn!("secrets enabled without a stable `secret_key`"); launch_info_!("disable `secrets` feature or configure a `secret_key`"); @@ -348,10 +379,9 @@ impl Config { } } - launch_info_!("temp dir: {}", Paint::default(&self.temp_dir.relative().display()).bold()); - launch_info_!("log level: {}", Paint::default(self.log_level).bold()); - launch_info_!("cli colors: {}", Paint::default(&self.cli_colors).bold()); - launch_info_!("shutdown: {}", Paint::default(&self.shutdown).bold()); + launch_info_!("shutdown: {}", bold(&self.shutdown)); + launch_info_!("log level: {}", bold(self.log_level)); + launch_info_!("cli colors: {}", bold(&self.cli_colors)); // Check for now depreacted config values. for (key, replacement) in Self::DEPRECATED_KEYS { @@ -399,7 +429,6 @@ impl Config { /// The default profile: "debug" on `debug`, "release" on `release`. #[cfg(not(debug_assertions))] pub const DEFAULT_PROFILE: Profile = Self::RELEASE_PROFILE; - } /// Associated constants for stringy versions of configuration parameters. diff --git a/core/lib/src/config/mod.rs b/core/lib/src/config/mod.rs index 4ae360b9..8b577e77 100644 --- a/core/lib/src/config/mod.rs +++ b/core/lib/src/config/mod.rs @@ -131,6 +131,9 @@ pub use ident::Ident; #[cfg(feature = "tls")] pub use tls::{TlsConfig, CipherSuite}; +#[cfg(feature = "mtls")] +pub use tls::MutualTls; + #[cfg(feature = "secrets")] pub use secret_key::SecretKey; @@ -423,6 +426,70 @@ mod tests { }); } + #[test] + #[cfg(feature = "mtls")] + fn test_mtls_config() { + use std::path::Path; + + figment::Jail::expect_with(|jail| { + jail.create_file("Rocket.toml", r#" + [default.tls] + certs = "/ssl/cert.pem" + key = "/ssl/key.pem" + "#)?; + + let config = Config::from(Config::figment()); + assert!(config.tls.is_some()); + assert!(config.tls.as_ref().unwrap().mutual.is_none()); + assert!(config.tls_enabled()); + assert!(!config.mtls_enabled()); + + jail.create_file("Rocket.toml", r#" + [default.tls] + certs = "/ssl/cert.pem" + key = "/ssl/key.pem" + mutual = { ca_certs = "/ssl/ca.pem" } + "#)?; + + let config = Config::from(Config::figment()); + assert!(config.tls_enabled()); + assert!(config.mtls_enabled()); + + let mtls = config.tls.as_ref().unwrap().mutual.as_ref().unwrap(); + assert_eq!(mtls.ca_certs().unwrap_left(), Path::new("/ssl/ca.pem")); + assert!(!mtls.mandatory); + + jail.create_file("Rocket.toml", r#" + [default.tls] + certs = "/ssl/cert.pem" + key = "/ssl/key.pem" + + [default.tls.mutual] + ca_certs = "/ssl/ca.pem" + mandatory = true + "#)?; + + let config = Config::from(Config::figment()); + let mtls = config.tls.as_ref().unwrap().mutual.as_ref().unwrap(); + assert_eq!(mtls.ca_certs().unwrap_left(), Path::new("/ssl/ca.pem")); + assert!(mtls.mandatory); + + jail.create_file("Rocket.toml", r#" + [default.tls] + certs = "/ssl/cert.pem" + key = "/ssl/key.pem" + mutual = { ca_certs = "relative/ca.pem" } + "#)?; + + let config = Config::from(Config::figment()); + let mtls = config.tls.as_ref().unwrap().mutual().unwrap(); + assert_eq!(mtls.ca_certs().unwrap_left(), + jail.directory().join("relative/ca.pem")); + + Ok(()) + }); + } + #[test] fn test_profiles_merge() { figment::Jail::expect_with(|jail| { diff --git a/core/lib/src/config/secret_key.rs b/core/lib/src/config/secret_key.rs index c4e70214..adf7e528 100644 --- a/core/lib/src/config/secret_key.rs +++ b/core/lib/src/config/secret_key.rs @@ -246,7 +246,7 @@ impl<'de> Deserialize<'de> for SecretKey { } } -impl fmt::Debug for SecretKey { +impl fmt::Display for SecretKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.is_zero() { f.write_str("[zero]") @@ -258,3 +258,9 @@ impl fmt::Debug for SecretKey { } } } + +impl fmt::Debug for SecretKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + ::fmt(self, f) + } +} diff --git a/core/lib/src/config/tls.rs b/core/lib/src/config/tls.rs index 67fb0bbd..556c6b59 100644 --- a/core/lib/src/config/tls.rs +++ b/core/lib/src/config/tls.rs @@ -26,44 +26,63 @@ use indexmap::IndexSet; /// ciphersuite preferences over the client's. The default and recommended /// value is `false`. /// -/// The following example illustrates manual configuration: +/// Additionally, the `mutual` parameter controls if and how the server +/// authenticates clients via mutual TLS. It works in concert with the +/// [`mtls`](crate::mtls) module. See [`MutualTls`] for configuration details. +/// +/// In `Rocket.toml`, configuration might look like: +/// +/// ```toml +/// [default.tls] +/// certs = "private/rsa_sha256_cert.pem" +/// key = "private/rsa_sha256_key.pem" +/// ``` +/// +/// With a custom programmatic configuration, this might look like: /// /// ```rust +/// # #[macro_use] extern crate rocket; /// use rocket::config::{Config, TlsConfig, CipherSuite}; /// -/// // From a manually constructed figment. -/// let figment = rocket::Config::figment() +/// #[launch] +/// fn rocket() -> _ { +/// let tls_config = TlsConfig::from_paths("/ssl/certs.pem", "/ssl/key.pem") +/// .with_ciphers(CipherSuite::TLS_V13_SET) +/// .with_preferred_server_cipher_order(true); +/// +/// let config = Config { +/// tls: Some(tls_config), +/// ..Default::default() +/// }; +/// +/// rocket::custom(config) +/// } +/// ``` +/// +/// Or by creating a custom figment: +/// +/// ```rust +/// use rocket::config::Config; +/// +/// let figment = Config::figment() /// .merge(("tls.certs", "path/to/certs.pem")) /// .merge(("tls.key", vec![0; 32])); -/// -/// let config = rocket::Config::from(figment); -/// let tls_config = config.tls.as_ref().unwrap(); -/// assert!(tls_config.certs().is_left()); -/// assert!(tls_config.key().is_right()); -/// assert_eq!(tls_config.ciphers().count(), 9); -/// assert!(!tls_config.prefer_server_cipher_order()); -/// -/// // From a serialized `TlsConfig`. -/// let tls_config = TlsConfig::from_paths("/ssl/certs.pem", "/ssl/key.pem") -/// .with_ciphers(CipherSuite::TLS_V13_SET) -/// .with_preferred_server_cipher_order(true); -/// -/// let figment = rocket::Config::figment() -/// .merge(("tls", tls_config)); -/// -/// let config = rocket::Config::from(figment); -/// let tls_config = config.tls.as_ref().unwrap(); -/// assert_eq!(tls_config.ciphers().count(), 3); -/// assert!(tls_config.prefer_server_cipher_order()); +/// # +/// # let config = rocket::Config::from(figment); +/// # let tls_config = config.tls.as_ref().unwrap(); +/// # assert!(tls_config.certs().is_left()); +/// # assert!(tls_config.key().is_right()); +/// # assert_eq!(tls_config.ciphers().count(), 9); +/// # assert!(!tls_config.prefer_server_cipher_order()); /// ``` #[derive(PartialEq, Debug, Clone, Deserialize, Serialize)] #[cfg_attr(nightly, doc(cfg(feature = "tls")))] -#[serde(deny_unknown_fields)] pub struct TlsConfig { - /// Path or raw bytes for the DER-encoded X.509 TLS certificate chain. + /// Path to a PEM file with, or raw bytes for, a DER-encoded X.509 TLS + /// certificate chain. pub(crate) certs: Either>, - /// Path or raw bytes to DER-encoded ASN.1 key in either PKCS#8 or PKCS#1 - /// format. + /// Path to a PEM file with, or raw bytes for, DER-encoded private key in + /// either PKCS#8 or PKCS#1 format. pub(crate) key: Either>, /// List of TLS cipher suites in server-preferred order. #[serde(default = "CipherSuite::default_set")] @@ -78,11 +97,77 @@ pub struct TlsConfig { pub(crate) mutual: Option, } +/// Mutual TLS configuration. +/// +/// Configuration works in concert with the [`mtls`](crate::mtls) module, which +/// provides a request guard to validate, verify, and retrieve client +/// certificates in routes. +/// +/// By default, mutual TLS is disabled and client certificates are not required, +/// validated or verified. To enable mutual TLS, the `mtls` feature must be +/// enabled and support configured via two `tls.mutual` parameters: +/// +/// * `ca_certs` +/// +/// A required path to a PEM file or raw bytes to a DER-encoded X.509 TLS +/// certificate chain for the certificate authority to verify client +/// certificates against. When a path is configured in a file, such as +/// `Rocket.toml`, relative paths are interpreted as relative to the source +/// file's directory. +/// +/// * `mandatory` +/// +/// An optional boolean that control whether client authentication is +/// required. +/// +/// When `true`, client authentication is required. TLS connections where +/// the client does not present a certificate are immediately terminated. +/// When `false`, the client is not required to present a certificate. In +/// either case, if a certificate _is_ presented, it must be valid or the +/// connection is terminated. +/// +/// In a `Rocket.toml`, configuration might look like: +/// +/// ```toml +/// [default.tls.mutual] +/// ca_certs = "/ssl/ca_cert.pem" +/// mandatory = true # when absent, defaults to false +/// ``` +/// +/// Programmatically, configuration might look like: +/// +/// ```rust +/// # #[macro_use] extern crate rocket; +/// use rocket::config::{Config, TlsConfig, MutualTls}; +/// +/// #[launch] +/// fn rocket() -> _ { +/// let tls_config = TlsConfig::from_paths("/ssl/certs.pem", "/ssl/key.pem") +/// .with_mutual(MutualTls::from_path("/ssl/ca_cert.pem")); +/// +/// let config = Config { +/// tls: Some(tls_config), +/// ..Default::default() +/// }; +/// +/// rocket::custom(config) +/// } +/// ``` #[derive(PartialEq, Debug, Clone, Deserialize, Serialize)] #[cfg(feature = "mtls")] #[cfg_attr(nightly, doc(cfg(feature = "mtls")))] pub struct MutualTls { + /// Path to a PEM file with, or raw bytes for, DER-encoded Certificate + /// Authority certificates which will be used to verify client-presented + /// certificates. + // TODO: We should support more than one root. pub(crate) ca_certs: Either>, + /// Whether the client is required to present a certificate. + /// + /// When `true`, the client is required to present a valid certificate to + /// proceed with TLS. When `false`, the client is not required to present a + /// certificate. In either case, if a certificate _is_ presented, it must be + /// valid or the connection is terminated. #[serde(default)] #[serde(deserialize_with = "figment::util::bool_from_str_or_int")] pub mandatory: bool, diff --git a/core/lib/src/data/data_stream.rs b/core/lib/src/data/data_stream.rs index c87fde74..7e4171c5 100644 --- a/core/lib/src/data/data_stream.rs +++ b/core/lib/src/data/data_stream.rs @@ -52,7 +52,7 @@ pub struct StreamReader<'r> { /// The current state of `StreamReader` `AsyncRead` adapter. enum State { Pending, - Partial(Cursor), + Partial(Cursor), Done, } @@ -257,7 +257,7 @@ impl AsyncRead for DataStream<'_> { } impl Stream for StreamKind<'_> { - type Item = io::Result; + type Item = io::Result; fn poll_next( self: Pin<&mut Self>, diff --git a/core/lib/src/lib.rs b/core/lib/src/lib.rs index 185babe0..3fe85b2c 100644 --- a/core/lib/src/lib.rs +++ b/core/lib/src/lib.rs @@ -63,6 +63,7 @@ //! |-----------|---------------------------------------------------------| //! | `secrets` | Support for authenticated, encrypted [private cookies]. | //! | `tls` | Support for [TLS] encrypted connections. | +//! | `mtls` | Support for verified clients via [mutual TLS]. | //! | `json` | Support for [JSON (de)serialization]. | //! | `msgpack` | Support for [MessagePack (de)serialization]. | //! | `uuid` | Support for [UUID value parsing and (de)serialization]. | @@ -79,6 +80,7 @@ //! [UUID value parsing and (de)serialization]: crate::serde::uuid //! [private cookies]: https://rocket.rs/v0.5-rc/guide/requests/#private-cookies //! [TLS]: https://rocket.rs/v0.5-rc/guide/configuration/#tls +//! [mutual TLS]: crate::mtls //! //! ## Configuration //! @@ -137,6 +139,10 @@ pub mod http { pub use crate::cookies::*; } +#[cfg(feature = "mtls")] +#[cfg_attr(nightly, doc(cfg(feature = "mtls")))] +pub mod mtls; + /// TODO: We need a futures mod or something. mod trip_wire; mod shutdown; diff --git a/core/lib/src/mtls.rs b/core/lib/src/mtls.rs new file mode 100644 index 00000000..40ac00c5 --- /dev/null +++ b/core/lib/src/mtls.rs @@ -0,0 +1,24 @@ +//! Support for mutual TLS client certificates. +//! +//! For details on how to configure mutual TLS, see +//! [`MutualTls`](crate::config::MutualTls) and the [TLS +//! guide](https://rocket.rs/v0.5-rc/guide/configuration/#tls). See +//! [`Certificate`] for a request guard that validated, verifies, and retrieves +//! client certificates. + +#[doc(inline)] +pub use crate::http::tls::mtls::*; + +use crate::request::{Request, FromRequest, Outcome}; +use crate::outcome::{try_outcome, IntoOutcome}; +use crate::http::Status; + +#[crate::async_trait] +impl<'r> FromRequest<'r> for Certificate<'r> { + type Error = Error; + + async fn from_request(req: &'r Request<'_>) -> Outcome { + let certs = try_outcome!(req.connection.client_certificates.as_ref().or_forward(())); + Certificate::parse(certs).into_outcome(Status::Unauthorized) + } +} diff --git a/core/lib/src/request/mod.rs b/core/lib/src/request/mod.rs index e5d8e0bc..5849fb79 100644 --- a/core/lib/src/request/mod.rs +++ b/core/lib/src/request/mod.rs @@ -14,6 +14,8 @@ pub use self::from_param::{FromParam, FromSegments}; #[doc(inline)] pub use crate::response::flash::FlashMessage; +pub(crate) use self::request::ConnectionMeta; + crate::export! { /// Store and immediately retrieve a value `$v` in `$request`'s local cache /// using a locally generated anonymous type to avoid type conflicts. diff --git a/core/lib/src/request/request.rs b/core/lib/src/request/request.rs index 59639baa..c54169b3 100644 --- a/core/lib/src/request/request.rs +++ b/core/lib/src/request/request.rs @@ -8,16 +8,16 @@ use state::{Container, Storage}; use futures::future::BoxFuture; use atomic::{Atomic, Ordering}; -// use crate::request::{FromParam, FromSegments, FromRequest, Outcome}; +use crate::{Rocket, Route, Orbit}; use crate::request::{FromParam, FromSegments, FromRequest, Outcome}; use crate::form::{self, ValueField, FromForm}; +use crate::data::Limits; -use crate::{Rocket, Route, Orbit}; -use crate::http::uri::{fmt::Path, Origin, Segments, Host, Authority}; use crate::http::{hyper, Method, Header, HeaderMap}; use crate::http::{ContentType, Accept, MediaType, CookieJar, Cookie}; use crate::http::uncased::UncasedStr; -use crate::data::Limits; +use crate::http::private::RawCertificate; +use crate::http::uri::{fmt::Path, Origin, Segments, Host, Authority}; /// The type of an incoming web request. /// @@ -28,12 +28,19 @@ use crate::data::Limits; pub struct Request<'r> { method: Atomic, uri: Origin<'r>, - host: Option>, headers: HeaderMap<'r>, - remote: Option, + pub(crate) connection: ConnectionMeta, pub(crate) state: RequestState<'r>, } +/// Information derived from an incoming connection, if any. +#[derive(Clone)] +pub(crate) struct ConnectionMeta { + pub remote: Option, + pub client_certificates: Option>>, +} + +/// Information derived from the request. pub(crate) struct RequestState<'r> { pub rocket: &'r Rocket, pub route: Atomic>, @@ -41,6 +48,7 @@ pub(crate) struct RequestState<'r> { pub accept: Storage>, pub content_type: Storage>, pub cache: Arc, + pub host: Option>, } impl Request<'_> { @@ -48,9 +56,8 @@ impl Request<'_> { Request { method: Atomic::new(self.method()), uri: self.uri.clone(), - host: self.host.clone(), headers: self.headers.clone(), - remote: self.remote, + connection: self.connection.clone(), state: self.state.clone(), } } @@ -65,6 +72,7 @@ impl RequestState<'_> { accept: self.accept.clone(), content_type: self.content_type.clone(), cache: self.cache.clone(), + host: self.host.clone(), } } } @@ -79,10 +87,12 @@ impl<'r> Request<'r> { ) -> Request<'r> { Request { uri, - host: None, method: Atomic::new(method), headers: HeaderMap::new(), - remote: None, + connection: ConnectionMeta { + remote: None, + client_certificates: None, + }, state: RequestState { rocket, route: Atomic::new(None), @@ -90,6 +100,7 @@ impl<'r> Request<'r> { accept: Storage::new(), content_type: Storage::new(), cache: Arc::new(::new()), + host: None, } } } @@ -179,6 +190,10 @@ impl<'r> Request<'r> { /// component. Otherwise, this method returns the contents of the /// `:authority` pseudo-header request field. /// + /// Note that this method _only_ reflects the `HOST` header in the _initial_ + /// request and not any changes made thereafter. To change the value + /// returned by this method, use [`Request::set_host()`]. + /// /// # ⚠️ DANGER ⚠️ /// /// Using the user-controlled `host` to construct URLs is a security hazard! @@ -257,7 +272,7 @@ impl<'r> Request<'r> { /// ``` #[inline(always)] pub fn host(&self) -> Option<&Host<'r>> { - self.host.as_ref() + self.state.host.as_ref() } /// Sets the host of `self` to `host`. @@ -282,7 +297,7 @@ impl<'r> Request<'r> { /// ``` #[inline(always)] pub fn set_host(&mut self, host: Host<'r>) { - self.host = Some(host); + self.state.host = Some(host); } /// Returns the raw address of the remote connection that initiated this @@ -314,7 +329,7 @@ impl<'r> Request<'r> { /// ``` #[inline(always)] pub fn remote(&self) -> Option { - self.remote + self.connection.remote } /// Sets the remote address of `self` to `address`. @@ -337,7 +352,7 @@ impl<'r> Request<'r> { /// ``` #[inline(always)] pub fn set_remote(&mut self, address: SocketAddr) { - self.remote = Some(address); + self.connection.remote = Some(address); } /// Returns the IP address in the "X-Real-IP" header of the request if such @@ -949,8 +964,8 @@ impl<'r> Request<'r> { /// Convert from Hyper types into a Rocket Request. pub(crate) fn from_hyp( rocket: &'r Rocket, - hyper: &'r hyper::RequestParts, - addr: SocketAddr + hyper: &'r hyper::request::Parts, + connection: Option, ) -> Result, Error<'r>> { // Ensure that the method is known. TODO: Allow made-up methods? let method = Method::from_hyp(&hyper.method) @@ -965,11 +980,13 @@ impl<'r> Request<'r> { // Construct the request object. let mut request = Request::new(rocket, method, uri); - request.set_remote(addr); + if let Some(connection) = connection { + request.connection = connection; + } // Determine the host. On HTTP < 2, use the `HOST` header. Otherwise, // use the `:authority` pseudo-header which hyper makes part of the URI. - request.host = if hyper.version < hyper::Version::HTTP_2 { + request.state.host = if hyper.version < hyper::Version::HTTP_2 { hyper.headers.get("host").and_then(|h| Host::parse_bytes(h.as_bytes()).ok()) } else { hyper.uri.host().map(|h| Host::new(Authority::new(None, h, hyper.uri.port_u16()))) diff --git a/core/lib/src/request/tests.rs b/core/lib/src/request/tests.rs index 2bab068e..a349aeda 100644 --- a/core/lib/src/request/tests.rs +++ b/core/lib/src/request/tests.rs @@ -1,4 +1,3 @@ -use std::net::{Ipv4Addr, SocketAddrV4}; use std::collections::HashMap; use crate::Request; @@ -17,9 +16,8 @@ macro_rules! assert_headers { // Create a valid `Rocket` and convert the hyper req to a Rocket one. let client = Client::debug_with(vec![]).unwrap(); - let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 8000).into(); let hyper = req.into_parts().0; - let req = Request::from_hyp(client.rocket(), &hyper, addr).unwrap(); + let req = Request::from_hyp(client.rocket(), &hyper, None).unwrap(); // Dispatch the request and check that the headers match. let actual_headers = req.headers(); diff --git a/core/lib/src/server.rs b/core/lib/src/server.rs index 45b25add..ba2b78e9 100644 --- a/core/lib/src/server.rs +++ b/core/lib/src/server.rs @@ -12,6 +12,7 @@ use crate::form::Form; use crate::outcome::Outcome; use crate::error::{Error, ErrorKind}; use crate::ext::{AsyncReadExt, CancellableListener, CancellableIo}; +use crate::request::ConnectionMeta; use crate::http::{uri::Origin, hyper, Method, Status, Header}; use crate::http::private::{bind_tcp, Listener, Connection, Incoming}; @@ -61,7 +62,7 @@ async fn handle(name: Option<&str>, run: F) -> Option // `HyperResponse` type, this function does the actual response processing. async fn hyper_service_fn( rocket: Arc>, - addr: std::net::SocketAddr, + conn: ConnectionMeta, hyp_req: hyper::Request, ) -> Result, io::Error> { // This future must return a hyper::Response, but the response body might @@ -72,7 +73,7 @@ async fn hyper_service_fn( tokio::spawn(async move { // Convert a Hyper request into a Rocket request. let (h_parts, mut h_body) = hyp_req.into_parts(); - match Request::from_hyp(&rocket, &h_parts, addr) { + match Request::from_hyp(&rocket, &h_parts, Some(conn)) { Ok(mut req) => { // Convert into Rocket `Data`, dispatch request, write response. let mut data = Data::from(&mut h_body); @@ -366,20 +367,18 @@ impl Rocket { .map_err(|e| Error::new(ErrorKind::Io(e)))?; #[cfg(feature = "tls")] - if let Some(ref config) = self.config.tls { - use crate::http::tls::TlsListener; + if self.config.tls_enabled() { + if let Some(ref config) = self.config.tls { + use crate::http::tls::TlsListener; - let (certs, key) = config.to_readers().map_err(ErrorKind::Io)?; - let ciphers = config.rustls_ciphers(); - let server_order = config.prefer_server_cipher_order; - let l = TlsListener::bind(addr, certs, key, ciphers, server_order).await - .map_err(ErrorKind::Bind)?; - - addr = l.local_addr().unwrap_or(addr); - self.config.address = addr.ip(); - self.config.port = addr.port(); - ready(&mut self).await; - return self.http_server(l).await; + let conf = config.to_native_config().map_err(ErrorKind::Io)?; + let l = TlsListener::bind(addr, conf).await.map_err(ErrorKind::Bind)?; + addr = l.local_addr().unwrap_or(addr); + self.config.address = addr.ip(); + self.config.port = addr.port(); + ready(&mut self).await; + return self.http_server(l).await; + } } let l = bind_tcp(addr).await.map_err(ErrorKind::Bind)?; @@ -443,10 +442,14 @@ impl Rocket { let rocket = Arc::new(self); let service_fn = move |conn: &CancellableIo<_, L::Connection>| { let rocket = rocket.clone(); - let remote = conn.peer_address().unwrap_or_else(|| ([0, 0, 0, 0], 0).into()); + let connection = ConnectionMeta { + remote: conn.peer_address(), + client_certificates: conn.peer_certificates().map(Arc::new), + }; + async move { - Ok::<_, std::convert::Infallible>(hyper::service_fn(move |req| { - hyper_service_fn(rocket.clone(), remote, req) + Ok::<_, std::convert::Infallible>(hyper::service::service_fn(move |req| { + hyper_service_fn(rocket.clone(), connection.clone(), req) })) } }; @@ -457,7 +460,7 @@ impl Rocket { .http1_keepalive(http1_keepalive) .http1_preserve_header_case(true) .http2_keep_alive_interval(http2_keep_alive) - .serve(hyper::make_service_fn(service_fn)) + .serve(hyper::service::make_service_fn(service_fn)) .with_graceful_shutdown(shutdown.clone()) .map_err(|e| Error::new(ErrorKind::Runtime(Box::new(e)))); diff --git a/scripts/test.sh b/scripts/test.sh index fdeae8f3..9e3d6d29 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -102,6 +102,7 @@ function test_core() { FEATURES=( secrets tls + mtls json msgpack uuid diff --git a/site/guide/9-configuration.md b/site/guide/9-configuration.md index 600d8beb..8998c2ae 100644 --- a/site/guide/9-configuration.md +++ b/site/guide/9-configuration.md @@ -236,24 +236,8 @@ Security). To enable TLS support: certs = "path/to/certs.pem" # Path or bytes to DER-encoded X.509 TLS cert chain. ``` -Next time the server is run, Rocket will report that TLS is enabled during -ignition: - -```text -🔧 Configured for debug. - ... - >> tls: enabled -``` - -The [TLS example](@example/tls) illustrates a fully configured TLS server. - -! warning: Rocket's built-in TLS supports only TLS 1.2 and 1.3. This may not be - suitable for production use. - -#### TLS Parameters - -The `tls` parameter is expected to be a dictionary with at-most four keys which -deserialize into the [`TlsConfig`] structure. These are: +The `tls` parameter is expected to be a dictionary that deserializes into a +[`TlsConfig`] structure: | key | required | type | |------------------------------|-----------|-------------------------------------------------------| @@ -261,9 +245,11 @@ deserialize into the [`TlsConfig`] structure. These are: | `certs` | **_yes_** | Path or bytes to DER-encoded X.509 TLS cert chain. | | `ciphers` | no | Array of [`CipherSuite`]s to enable. | | `prefer_server_cipher_order` | no | Boolean for whether to [prefer server cipher suites]. | +| `mutual` | no | A map with [mutual TLS] configuration. | [`CipherSuite`]: @api/rocket/config/enum.CipherSuite.html [prefer server cipher suites]: @api/rocket/config/struct.TlsConfig.html#method.with_preferred_server_cipher_order +[mutual TLS]: #mutual-tls When specified via TOML or other serialized formats, each [`CipherSuite`] is written as a string representation of the respective variant. For example, @@ -288,16 +274,69 @@ ciphers = [ ] ``` +### Mutual TLS + +Rocket supports mutual TLS client authentication. Configuration works in concert +with the [`mtls`] module, which provides a request guard to validate, verify, +and retrieve client certificates in routes. + +By default, mutual TLS is disabled and client certificates are not required, +validated or verified. To enable mutual TLS, the `mtls` feature must be +enabled and support configured via the `tls.mutual` config parameter: + + 1. Enable the `mtls` crate feature in `Cargo.toml`: + + ```toml,ignore + [dependencies] + rocket = { version = "0.5.0-rc.1", features = ["mtls"] } + ``` + + This implicitly enables the `tls` feature. + + 2. Configure a CA certificate chain via the `tls.mutual.ca_certs` + configuration parameter. With the default provider, this can be done via + `Rocket.toml` as: + + ```toml,ignore + [default.tls.mutual] + ca_certs = "path/to/ca_certs.pem" # Path or bytes to DER-encoded X.509 TLS cert chain. + mandatory = true # when absent, defaults to false + ``` + +The `tls.mutual` parameter is expected to be a dictionary that deserializes into a +[`MutualTls`] structure: + +| key | required | type | +|-------------|-----------|-------------------------------------------------------------| +| `ca_certs` | **_yes_** | Path or bytes to DER-encoded X.509 TLS cert chain. | +| `mandatory` | no | Boolean controlling whether the client _must_ authenticate. | + +[`MutualTls`]: @api/rocket/config/struct.MutualTls.html +[`mtls`]: @api/rocket/mtls/index.html + +Rocket reports if TLS and/or mTLS are enabled at launch time: + +```text +🔧 Configured for debug. + ... + >> tls: enabled w/mtls +``` + +The [TLS example](@example/tls) illustrates a fully configured TLS server with +mutual TLS. + +! warning: Rocket's built-in TLS supports only TLS 1.2 and 1.3. This may not be + suitable for production use. + ### Workers The `workers` parameter sets the number of threads used for parallel task execution; there is no limit to the number of concurrent tasks. Due to a limitation in upstream async executers, unlike other values, the `workers` configuration value cannot be reconfigured or be configured from sources other -than those provided by [`Config::figment()`], detailed below. In other words, -only the values set by the `ROCKET_WORKERS` environment variable or in the -`workers` property of `Rocket.toml` will be considered - all other `workers` -values are ignored. +than those provided by [`Config::figment()`]. In other words, only the values +set by the `ROCKET_WORKERS` environment variable or in the `workers` property of +`Rocket.toml` will be considered - all other `workers` values are ignored. ## Extracting Values