Skip to content

Commit

Permalink
🤝 Honor backend CA certificates (#305)
Browse files Browse the repository at this point in the history
This should work for both static certificates in the TOML file, presuming you can get them in in a reasonable format, and through dynamic backends.

I've added some test cases, which required adding a serial_test dependency so that the existing client cert test doesn't stomp over the execution requirement. I figured it was nice to leave this functionality in, as there may be more complicated situations in which just using a dynamic backends flag was inconvenient.

* Wire provided CA certs into the backend.
* Pipe the provided CA certs into the rustls config.
* Add test cases to support that this all works.
* Add some basic input validation to the ca_cert read.
* Add a CHANGELOG entry.
* Use only the provided cert, not that plus our normal roots.
* Add some more parsing options for ca certs, and  config-parsing unit-tests.
  • Loading branch information
acw authored Mar 15, 2024
1 parent 97a67fa commit 22f3dfd
Show file tree
Hide file tree
Showing 11 changed files with 419 additions and 29 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## Unreleased

- Bug fix: Honor CA certificates when they are supplied, either as part of a dynamic backend
definition or as part of a backend defined in fastly.toml. (In the latter case, CA certificates
can be added using the "ca_certificate" key.)

## 0.9.4 (2024-02-22)

- Added `delete_async` hostcall for KV stores ([#332](https://github.com/fastly/Viceroy/pull/332))
Expand Down
39 changes: 39 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ base64 = { workspace = true }
hyper = { workspace = true }
itertools = { workspace = true }
serde_json = { workspace = true }
serial_test = "^2.0.0"
clap = { workspace = true }
rustls = { workspace = true }
rustls-pemfile = { workspace = true }
Expand Down
76 changes: 76 additions & 0 deletions cli/tests/integration/client_certs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,79 @@ fn build_server_tls_config() -> ServerConfig {
}

#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn custom_ca_works() -> TestResult {
let test = Test::using_fixture("mutual-tls.wasm");
let server_addr: SocketAddr = "127.0.0.1:0".parse().expect("localhost parses");
let incoming = AddrIncoming::bind(&server_addr).expect("bind");
let bound_port = incoming.local_addr().port();

let acceptor = TlsAcceptor::from(Arc::new(build_server_tls_config()));
let listener = TlsListener::new_hyper(acceptor, incoming);

let service = make_service_fn(|stream: &TlsStream<_>| {
let (_, server_connection) = stream.get_ref();
let peer_certs = server_connection.peer_certificates().map(|x| x.to_vec());
async move {
Ok::<_, std::io::Error>(service_fn(move |_req| {
let peer_certs = peer_certs.clone();

async {
match peer_certs {
None => response::Builder::new()
.status(401)
.body("could not identify client certificate".to_string()),
Some(vec) if vec.len() != 1 => response::Builder::new()
.status(406)
.body(format!("can only handle 1 cert, got {}", vec.len())),
Some(mut cert_vec) => {
let Certificate(cert) = cert_vec.remove(0);
let base64_cert = general_purpose::STANDARD.encode(cert);
response::Builder::new().status(200).body(base64_cert)
}
}
}
}))
}
});
let server = Server::builder(listener).serve(service);
tokio::spawn(server);

// positive test: setting the CA should allow this
let resp = test
.against(
Request::post("/")
.header("port", bound_port)
.header("set-ca", "please")
.body("Hello, Viceroy!")
.unwrap(),
)
.await;
let resp = resp.expect("got response");
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.into_body().read_into_string().await?,
"Hello, Viceroy!"
);

// negative test: if we don't set the CA, we should get a failure
let resp = test
.against(
Request::post("/")
.header("port", bound_port)
.body("Hello, Viceroy!")
.unwrap(),
)
.await;
assert_eq!(
resp.expect("got response").status(),
StatusCode::SERVICE_UNAVAILABLE
);
Ok(())
}

#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn client_certs_work() -> TestResult {
// Set up the test harness
std::env::set_var(
Expand Down Expand Up @@ -174,6 +247,7 @@ async fn client_certs_work() -> TestResult {
.against(
Request::post("/")
.header("port", bound_port)
.header("set-ca", "please")
.body("Hello, Viceroy!")
.unwrap(),
)
Expand All @@ -184,5 +258,7 @@ async fn client_certs_work() -> TestResult {
"Hello, Viceroy!"
);

std::env::remove_var("SSL_CERT_FILE");

Ok(())
}
1 change: 1 addition & 0 deletions cli/tests/integration/common/backends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ impl TestBackends {
use_sni: backend.use_sni,
grpc: false,
client_cert: None,
ca_certs: vec![],
};
backends.insert(name.to_string(), Arc::new(backend_config));
}
Expand Down
59 changes: 59 additions & 0 deletions lib/src/config/backends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub struct Backend {
pub use_sni: bool,
pub grpc: bool,
pub client_cert: Option<ClientCertInfo>,
pub ca_certs: Vec<rustls::Certificate>,
}

/// A map of [`Backend`] definitions, keyed by their name.
Expand Down Expand Up @@ -127,6 +128,11 @@ mod deserialization {
.transpose()?
.unwrap_or(true);

let ca_certs = toml
.remove("ca_certificate")
.map(parse_ca_cert_section)
.unwrap_or_else(|| Ok(vec![]))?;

let grpc = toml
.remove("grpc")
.map(|grpc| {
Expand All @@ -149,7 +155,60 @@ mod deserialization {
grpc,
// NOTE: Update when we support client certs in static backends
client_cert: None,
ca_certs,
})
}
}

fn parse_ca_cert_section(
ca_cert: Value,
) -> Result<Vec<rustls::Certificate>, BackendConfigError> {
match ca_cert {
Value::String(ca_cert) if !ca_cert.trim().is_empty() => {
let mut cursor = std::io::Cursor::new(ca_cert);
rustls_pemfile::certs(&mut cursor)
.map_err(|e| BackendConfigError::InvalidCACertEntry(format!("Couldn't process certificate: {}", e)))
.map(|mut x| {
x.drain(..)
.map(rustls::Certificate)
.collect::<Vec<rustls::Certificate>>()
})
}
Value::String(_) => Err(BackendConfigError::EmptyCACert),

Value::Array(array) => {
let mut result = vec![];

for item in array.into_iter() {
let mut current = parse_ca_cert_section(item)?;
result.append(&mut current);
}

Ok(result)
}

Value::Table(mut table) => {
match table.remove("file") {
None => match table.remove("value") {
None => Err(BackendConfigError::InvalidCACertEntry("'ca_certificate' was a dictionary without a 'file' or 'value' field".to_string())),
Some(strval @ Value::String(_)) => parse_ca_cert_section(strval),
Some(_) => Err(BackendConfigError::InvalidCACertEntry("invalid format for 'value' field".to_string())),
},
Some(Value::String(x)) => {
if !table.is_empty() {
return Err(BackendConfigError::InvalidCACertEntry(format!("unknown ca_certificate keys: {:?}", table.keys().collect::<Vec<_>>())));
}

let data = std::fs::read_to_string(&x)
.map_err(|e| BackendConfigError::InvalidCACertEntry(format!("{}", e)))?;
parse_ca_cert_section(Value::String(data))
}

Some(_) => Err(BackendConfigError::InvalidCACertEntry("invalid format for file reference".to_string())),
}
}

_ => Err(BackendConfigError::InvalidCACertEntry("unknown format for 'ca_certificates' field; should be a certificate string, a dictionary with a file reference, or an array of the previous".to_string())),
}
}
}
Loading

0 comments on commit 22f3dfd

Please sign in to comment.