Skip to content

Commit

Permalink
Merge pull request #2474 from jarhodes314/feat/file-transfer-tls
Browse files Browse the repository at this point in the history
Add HTTPS/certificate authentication support to file transfer service
  • Loading branch information
jarhodes314 authored Nov 30, 2023
2 parents b216239 + 55b8577 commit ea254bf
Show file tree
Hide file tree
Showing 47 changed files with 1,319 additions and 419 deletions.
18 changes: 16 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions crates/common/axum_tls/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,31 @@ homepage = { workspace = true }
repository = { workspace = true }
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[features]
error-matching = ["dep:reqwest"]
test-helpers = ["dep:assert_matches", "error-matching"]

[dependencies]
anyhow = { workspace = true }
assert_matches = { workspace = true, optional = true }
axum = { workspace = true }
axum-server = { workspace = true }
camino = { workspace = true }
futures = { workspace = true }
hyper = { workspace = true }
pin-project = { workspace = true }
reqwest = { workspace = true, features = [
"rustls-tls-native-roots",
], optional = true }
rustls = { workspace = true }
rustls-pemfile = { workspace = true }
tedge_config = { workspace = true }
tokio = { workspace = true }
tokio-rustls = { workspace = true }
tower = { workspace = true }
tracing = { workspace = true }
x509-parser = { workspace = true }
yansi = { workspace = true }

[dev-dependencies]
assert_matches = { workspace = true }
Expand Down
25 changes: 6 additions & 19 deletions crates/common/axum_tls/src/acceptor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use x509_parser::prelude::FromDer;
use x509_parser::prelude::X509Certificate;

#[derive(Debug, Clone)]
/// An [Acceptor](Accept) that accepts TLS connections via [rustls], or non TLS connections
pub struct Acceptor {
inner: RustlsAcceptor,
}
Expand All @@ -34,12 +35,14 @@ impl From<ServerConfig> for Acceptor {
}

#[derive(Debug, Clone)]
/// [Extension] data added to a request by [Acceptor]
pub struct TlsData {
/// The common name of the certificate used, if a client certificate was used
pub common_name: Option<Arc<str>>,
/// Whether the incoming request was made over HTTPS (`true`) or HTTP (`false`)
pub is_secure: bool,
}

/// An [axum_server::Acceptor] that accepts TLS connections via [rustls]
impl Acceptor {
pub fn new(config: ServerConfig) -> Self {
Self {
Expand Down Expand Up @@ -99,7 +102,7 @@ where
}
}

pub fn common_name<'a>(cert: Option<&'a (&[u8], X509Certificate)>) -> Option<&'a str> {
fn common_name<'a>(cert: Option<&'a (&[u8], X509Certificate)>) -> Option<&'a str> {
cert?.1.subject.iter_common_name().next()?.as_str().ok()
}

Expand All @@ -114,7 +117,6 @@ mod tests {
use reqwest::Client;
use reqwest::Identity;
use rustls::RootCertStore;
use std::error::Error;
use std::net::SocketAddr;
use std::net::TcpListener;

Expand Down Expand Up @@ -177,10 +179,7 @@ mod tests {
.get_with_scheme(Scheme::HTTPS, &client)
.await
.unwrap_err();
assert_matches::assert_matches!(
rustls_error_from_reqwest(&err),
rustls::Error::AlertReceived(rustls::AlertDescription::UnknownCA)
);
crate::error_matching::assert_error_matches(err, rustls::AlertDescription::UnknownCA);
}

#[tokio::test]
Expand All @@ -206,18 +205,6 @@ mod tests {
);
}

fn rustls_error_from_reqwest(err: &reqwest::Error) -> &rustls::Error {
(|| {
err.source()?
.downcast_ref::<hyper::Error>()?
.source()?
.downcast_ref::<std::io::Error>()?
.get_ref()?
.downcast_ref::<rustls::Error>()
})()
.unwrap()
}

struct Server {
certificate: Certificate,
port: u16,
Expand Down
180 changes: 180 additions & 0 deletions crates/common/axum_tls/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
use crate::load_cert;
use crate::load_pkey;
use crate::read_trust_store;
use crate::ssl_config;
use anyhow::anyhow;
use anyhow::Context;
use camino::Utf8Path;
use rustls::RootCertStore;
use std::fmt::Debug;
use std::fs::File;
use std::io;
use std::io::stderr;
use std::io::Cursor;
use std::io::IsTerminal;
use std::path::Path;
use tedge_config::OptionalConfig;
use tracing::info;
use yansi::Paint;

/// Loads the relevant [rustls::ServerConfig] from configured values for `cert_path`, `key_path` and `ca_path`
///
/// In production use, all the paths should be passed in as [OptionalConfig]s from [TEdgeConfig]
///
/// ```no_run
/// # fn main() -> anyhow::Result<()> {
/// use axum_tls::config::load_ssl_config;
/// use tedge_config::TEdgeConfig;
///
/// let config: TEdgeConfig = unimplemented!("read config");
///
/// let config = load_ssl_config(
/// config.http.cert_path.as_ref(),
/// config.http.key_path.as_ref(),
/// config.http.ca_path.as_ref(),
/// "File transfer service",
/// )?;
/// # Ok(())
/// # }
/// ```
///
/// In a test, we can instead use [InjectedValue]
///
/// ```
/// # fn main() -> anyhow::Result<()> {
/// use rustls::RootCertStore;
/// use axum_tls::config::{InjectedValue, load_ssl_config};
/// use tedge_config::{OptionalConfig, TEdgeConfig};
///
/// let cert = rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap();
/// let cert_pem = cert.serialize_pem().unwrap();
/// let key_pem = cert.serialize_private_key_pem();
///
/// let config = load_ssl_config(
/// OptionalConfig::present(InjectedValue(cert_pem), "http.cert_path"),
/// OptionalConfig::present(InjectedValue(key_pem), "http.key_path"),
/// OptionalConfig::<InjectedValue<RootCertStore>>::empty("http.ca_path"),
/// "File transfer service",
/// )?;
/// # Ok(())
/// # }
/// ```
///
pub fn load_ssl_config(
cert_path: OptionalConfig<impl PemReader>,
key_path: OptionalConfig<impl PemReader>,
ca_path: OptionalConfig<impl TrustStoreLoader>,
service_name: &'static str,
) -> anyhow::Result<Option<rustls::ServerConfig>> {
// TODO this could be moved somewhere more generic (e.g. where we initialize tracing subscriber)
if !stderr().is_terminal() {
yansi::Paint::disable();
}

let enabled = Paint::green("enabled").bold();
let disabled = Paint::red("disabled").bold();
let service_name = Paint::default(service_name).bold();
let cert_key = cert_path.key();
let key_key = key_path.key();
let ca_key = ca_path.key();
if let Some((cert, key)) = load_certificate_and_key(cert_path, key_path)? {
let trust_store = match ca_path.or_none() {
Some(path) => path
.load_trust_store()
.map(Some)
.with_context(|| format!("reading root certificates configured in `{ca_key}`",))?,
None => None,
};
let ca_state = if let Some(store) = &trust_store {
let count = store.len();
format!("{enabled} ({count} certificates found)")
} else {
format!("{disabled}")
};

info!(target: "HTTP Server", "{service_name} has HTTPS {enabled} (configured in `{cert_key}`/`{key_key}`) and certificate authentication {ca_state} (configured in `{ca_key}`)", );
Ok(Some(ssl_config(cert, key, trust_store)?))
} else {
info!(target: "HTTP Server", "{service_name} has HTTPS {disabled} (configured in `{cert_key}`/`{key_key}`) and certificate authentication {disabled} (configured in `{ca_key}`)");
Ok(None)
}
}

type CertKeyPair = (Vec<Vec<u8>>, Vec<u8>);

fn load_certificate_and_key(
cert_path: OptionalConfig<impl PemReader>,
key_path: OptionalConfig<impl PemReader>,
) -> anyhow::Result<Option<CertKeyPair>> {
let paths = tedge_config::all_or_nothing((cert_path.as_ref(), key_path.as_ref()))
.map_err(|e| anyhow!("{e}"))?;

if let Some((cert_file, key_file)) = paths {
Ok(Some((
load_cert(cert_file).with_context(|| {
format!("reading certificate configured in `{}`", cert_path.key())
})?,
load_pkey(key_file).with_context(|| {
format!("reading private key configured in `{}`", key_path.key())
})?,
)))
} else {
Ok(None)
}
}

pub trait PemReader: Debug {
type Read<'a>: io::Read
where
Self: 'a;

fn open(&self) -> io::Result<Self::Read<'_>>;
}

pub trait TrustStoreLoader {
fn load_trust_store(&self) -> anyhow::Result<RootCertStore>;
}

#[derive(Debug)]
/// An injected value, used to avoid reading from the file system in unit tests
///
/// For example, a certificate path can be replaced with an [InjectedValue<String>]
/// where the [String] inside is a PEM-encoded certificate
///
/// ```
/// use axum_tls::config::InjectedValue;
/// use axum_tls::load_cert;
/// let cert = rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap();
/// let pem_data = cert.serialize_pem().unwrap();
///
/// let loaded_chain = load_cert(&InjectedValue(pem_data)).unwrap();
///
/// assert_eq!(loaded_chain.len(), 1);
/// ```
pub struct InjectedValue<S>(pub S);

impl PemReader for InjectedValue<String> {
type Read<'a> = Cursor<&'a [u8]>;
fn open(&self) -> io::Result<Self::Read<'_>> {
Ok(Cursor::new(self.0.as_bytes()))
}
}

impl<P: AsRef<Path> + Debug + ?Sized> PemReader for P {
type Read<'a> = File where Self: 'a;
fn open(&self) -> io::Result<File> {
File::open(self)
}
}

impl<P: AsRef<Utf8Path> + 'static> TrustStoreLoader for P {
fn load_trust_store(&self) -> anyhow::Result<RootCertStore> {
read_trust_store(self.as_ref())
}
}

impl TrustStoreLoader for InjectedValue<RootCertStore> {
fn load_trust_store(&self) -> anyhow::Result<RootCertStore> {
Ok(self.0.clone())
}
}
22 changes: 22 additions & 0 deletions crates/common/axum_tls/src/error_matching.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
use std::error::Error;

#[cfg(any(test, feature = "test-helpers"))]
pub fn assert_error_matches(err: reqwest::Error, alert_description: rustls::AlertDescription) {
let rustls_err = match rustls_error_from_reqwest(&err) {
Some(err) => err,
None => panic!("{:?}", anyhow::Error::from(err)),
};
assert_matches::assert_matches!(
rustls_err,
rustls::Error::AlertReceived(des) if des == &alert_description
);
}

pub fn rustls_error_from_reqwest(err: &reqwest::Error) -> Option<&rustls::Error> {
err.source()?
.downcast_ref::<hyper::Error>()?
.source()?
.downcast_ref::<std::io::Error>()?
.get_ref()?
.downcast_ref::<rustls::Error>()
}
Loading

1 comment on commit ea254bf

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robot Results

✅ Passed ❌ Failed ⏭️ Skipped Total Pass % ⏱️ Duration
387 0 3 387 100 59m54.587999999s

Please sign in to comment.