use std::path::Path;
use std::sync::Arc;
use rustls::RootCertStore;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
#[derive(Debug, thiserror::Error)]
pub enum CaLoadError {
#[error("read CA file {path}: {source}")]
Read {
path: String,
#[source]
source: rustls::pki_types::pem::Error,
},
#[error("parse CA certificate in {path}: {source}")]
Parse {
path: String,
#[source]
source: rustls::pki_types::pem::Error,
},
#[error("add CA certificate from {path} to root store: {source}")]
Reject {
path: String,
#[source]
source: rustls::Error,
},
#[error("CA file {path} contained no certificates")]
Empty {
path: String,
},
}
pub fn load_ca_into(roots: &mut RootCertStore, path: &str) -> Result<(), CaLoadError> {
let mut added = 0usize;
for cert in CertificateDer::pem_file_iter(path).map_err(|source| CaLoadError::Read {
path: path.to_owned(),
source,
})? {
let cert = cert.map_err(|source| CaLoadError::Parse {
path: path.to_owned(),
source,
})?;
roots.add(cert).map_err(|source| CaLoadError::Reject {
path: path.to_owned(),
source,
})?;
added += 1;
}
if added == 0 {
return Err(CaLoadError::Empty {
path: path.to_owned(),
});
}
Ok(())
}
pub fn load_ca_pem_into(
roots: &mut RootCertStore,
label: &str,
pem: &[u8],
) -> Result<(), CaLoadError> {
let mut added = 0usize;
for cert in CertificateDer::pem_slice_iter(pem) {
let cert = cert.map_err(|source| CaLoadError::Parse {
path: label.to_owned(),
source,
})?;
roots.add(cert).map_err(|source| CaLoadError::Reject {
path: label.to_owned(),
source,
})?;
added += 1;
}
if added == 0 {
return Err(CaLoadError::Empty {
path: label.to_owned(),
});
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdentityPart {
AuthorityCertificate,
ClientCertificate,
ClientKey,
CertificateAndKey,
}
impl std::fmt::Display for IdentityPart {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::AuthorityCertificate => "authority certificate",
Self::ClientCertificate => "client certificate",
Self::ClientKey => "client key",
Self::CertificateAndKey => "certificate and key",
})
}
}
#[derive(Debug, thiserror::Error)]
#[error("the {part} for the {subject} at {path} did not load")]
pub struct ClientIdentityError {
subject: String,
part: IdentityPart,
path: String,
#[source]
source: Box<ClientIdentityCause>,
}
impl ClientIdentityError {
#[must_use]
pub const fn part(&self) -> IdentityPart {
self.part
}
#[must_use]
pub const fn cause(&self) -> &ClientIdentityCause {
&self.source
}
}
#[derive(Debug, thiserror::Error)]
pub enum ClientIdentityCause {
#[error(transparent)]
Authority(#[from] CaLoadError),
#[error(transparent)]
Read(#[from] std::io::Error),
#[error("{0}")]
Parse(String),
#[error(transparent)]
Build(#[from] rustls::Error),
}
pub fn mutual_tls_client_config(
subject: &str,
ca: &Path,
cert: &Path,
key: &Path,
) -> Result<Arc<rustls::ClientConfig>, ClientIdentityError> {
let fail = |part: IdentityPart, path: &Path, cause: ClientIdentityCause| ClientIdentityError {
subject: subject.to_owned(),
part,
path: path.display().to_string(),
source: Box::new(cause),
};
let read = |path: &Path, part: IdentityPart| -> Result<Vec<u8>, ClientIdentityError> {
std::fs::read(path).map_err(|err| fail(part, path, err.into()))
};
let ca_pem = read(ca, IdentityPart::AuthorityCertificate)?;
let mut roots = RootCertStore::empty();
load_ca_pem_into(&mut roots, subject, &ca_pem)
.map_err(|err| fail(IdentityPart::AuthorityCertificate, ca, err.into()))?;
let cert_pem = read(cert, IdentityPart::ClientCertificate)?;
let chain: Vec<CertificateDer<'static>> = CertificateDer::pem_slice_iter(&cert_pem)
.collect::<Result<_, _>>()
.map_err(|err| {
fail(
IdentityPart::ClientCertificate,
cert,
ClientIdentityCause::Parse(err.to_string()),
)
})?;
let key_pem = read(key, IdentityPart::ClientKey)?;
let private = PrivateKeyDer::from_pem_slice(&key_pem).map_err(|err| {
fail(
IdentityPart::ClientKey,
key,
ClientIdentityCause::Parse(err.to_string()),
)
})?;
rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_client_auth_cert(chain, private)
.map(Arc::new)
.map_err(|err| fail(IdentityPart::CertificateAndKey, cert, err.into()))
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
#[test]
fn missing_file_is_a_read_error() {
let mut roots = RootCertStore::empty();
let err = load_ca_into(&mut roots, "/nonexistent/ca.pem").unwrap_err();
assert!(matches!(err, CaLoadError::Read { .. }), "got {err:?}");
assert!(err.to_string().contains("ca.pem"), "got: {err}");
}
#[test]
fn empty_file_is_an_empty_error() {
let dir = std::env::temp_dir();
let path = dir.join(format!("polyc-crypto-tls-test-{}.pem", std::process::id()));
std::fs::write(&path, b"").unwrap();
let mut roots = RootCertStore::empty();
let err = load_ca_into(&mut roots, path.to_str().unwrap()).unwrap_err();
std::fs::remove_file(&path).ok();
assert!(matches!(err, CaLoadError::Empty { .. }), "got {err:?}");
}
#[test]
fn empty_pem_bytes_is_an_empty_error() {
let mut roots = RootCertStore::empty();
let err = load_ca_pem_into(&mut roots, "test CA", b"").unwrap_err();
assert!(matches!(err, CaLoadError::Empty { .. }), "got {err:?}");
assert!(err.to_string().contains("test CA"), "got: {err}");
}
#[test]
fn a_missing_client_identity_names_its_subject_and_the_piece_that_is_gone() {
let missing = Path::new("/nonexistent/polychrome/ca.crt");
let err = mutual_tls_client_config("State client", missing, missing, missing).unwrap_err();
assert_eq!(err.part(), IdentityPart::AuthorityCertificate);
assert!(
matches!(err.cause(), ClientIdentityCause::Read(_)),
"got {:?}",
err.cause()
);
let rendered = err.to_string();
assert_eq!(
rendered,
"the authority certificate for the State client at /nonexistent/polychrome/ca.crt \
did not load"
);
}
#[test]
fn every_identity_piece_reads_beside_its_subject() {
for (part, expected) in [
(
IdentityPart::AuthorityCertificate,
"the authority certificate for the State client at /x did not load",
),
(
IdentityPart::ClientCertificate,
"the client certificate for the State client at /x did not load",
),
(
IdentityPart::ClientKey,
"the client key for the State client at /x did not load",
),
(
IdentityPart::CertificateAndKey,
"the certificate and key for the State client at /x did not load",
),
] {
let err = ClientIdentityError {
subject: "State client".to_owned(),
part,
path: "/x".to_owned(),
source: Box::new(ClientIdentityCause::Parse("unused".to_owned())),
};
assert_eq!(err.to_string(), expected);
}
}
#[test]
fn an_empty_authority_bundle_is_refused_rather_than_trusting_nothing() {
let dir = std::env::temp_dir();
let ca = dir.join(format!("polyc-crypto-mtls-{}.pem", std::process::id()));
std::fs::write(&ca, b"").unwrap();
let err = mutual_tls_client_config("State client", &ca, &ca, &ca).unwrap_err();
std::fs::remove_file(&ca).ok();
assert_eq!(err.part(), IdentityPart::AuthorityCertificate);
assert!(
matches!(
err.cause(),
ClientIdentityCause::Authority(CaLoadError::Empty { .. })
),
"got {:?}",
err.cause()
);
}
}