use std::path::{Path, PathBuf};
use dynamic_config::Error;
#[derive(Clone, PartialEq, Eq)]
pub enum Pem {
File(PathBuf),
Bytes(Vec<u8>),
}
impl Pem {
#[must_use]
pub fn path(&self) -> Option<&Path> {
match self {
Self::File(path) => Some(path),
Self::Bytes(_) => None,
}
}
pub fn read(&self, described: &str, what: &str) -> Result<Vec<u8>, Error> {
match self {
Self::Bytes(bytes) => Ok(bytes.clone()),
Self::File(path) => std::fs::read(path).map_err(|error| {
Error::remote(format!(
"{described}: reading {what} from {}: {error}",
path.display()
))
}),
}
}
}
impl std::fmt::Debug for Pem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::File(path) => write!(f, "file {}", path.display()),
Self::Bytes(_) => f.write_str("<pem bytes>"),
}
}
}
pub type CertificateAndKey = (Vec<u8>, Vec<u8>);
#[derive(Clone, PartialEq, Eq)]
pub struct ClientCertificate {
certificate: Pem,
key: Pem,
}
impl ClientCertificate {
#[must_use]
pub fn certificate(&self) -> &Pem {
&self.certificate
}
#[must_use]
pub fn key(&self) -> &Pem {
&self.key
}
}
impl std::fmt::Debug for ClientCertificate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientCertificate")
.field("certificate", &self.certificate)
.field(
"key",
&match &self.key {
Pem::File(path) => format!("file {}", path.display()),
Pem::Bytes(_) => "<redacted>".to_owned(),
},
)
.finish()
}
}
#[derive(Clone, Default, PartialEq, Eq)]
pub struct TlsConfig {
ca: Option<Pem>,
client: Option<ClientCertificate>,
}
impl TlsConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_ca_certificate_file(mut self, path: impl Into<PathBuf>) -> Self {
self.ca = Some(Pem::File(path.into()));
self
}
#[must_use]
pub fn with_ca_certificate_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
self.ca = Some(Pem::Bytes(pem.into()));
self
}
#[must_use]
pub fn with_client_certificate_files(
mut self,
certificate: impl Into<PathBuf>,
key: impl Into<PathBuf>,
) -> Self {
self.client = Some(ClientCertificate {
certificate: Pem::File(certificate.into()),
key: Pem::File(key.into()),
});
self
}
#[must_use]
pub fn with_client_certificate_pem(
mut self,
certificate: impl Into<Vec<u8>>,
key: impl Into<Vec<u8>>,
) -> Self {
self.client = Some(ClientCertificate {
certificate: Pem::Bytes(certificate.into()),
key: Pem::Bytes(key.into()),
});
self
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.ca.is_none() && self.client.is_none()
}
#[must_use]
pub fn ca_certificate(&self) -> Option<&Pem> {
self.ca.as_ref()
}
#[must_use]
pub fn client_certificate(&self) -> Option<&ClientCertificate> {
self.client.as_ref()
}
pub fn ca_certificate_pem(&self, described: &str) -> Result<Option<Vec<u8>>, Error> {
self.ca
.as_ref()
.map(|pem| pem.read(described, "the CA certificate"))
.transpose()
}
pub fn client_certificate_pem(
&self,
described: &str,
) -> Result<Option<CertificateAndKey>, Error> {
let Some(client) = &self.client else {
return Ok(None);
};
let certificate = client
.certificate
.read(described, "the client certificate")?;
let key = client.key.read(described, "the client private key")?;
Ok(Some((certificate, key)))
}
}
impl std::fmt::Debug for TlsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TlsConfig")
.field("ca_certificate", &self.ca)
.field("client_certificate", &self.client)
.finish()
}
}
#[must_use]
pub fn unsupported(described: &str, setting: &str, instead: &str) -> Error {
Error::remote(format!(
"{described}: {setting} cannot be expressed here, and is refused \
rather than ignored; {instead}"
))
}
#[cfg(test)]
mod tests {
use super::*;
const PLANTED: &str = "PLANTED-PRIVATE-KEY-MATERIAL";
fn planted_key() -> String {
format!("-----BEGIN PRIVATE KEY-----\n{PLANTED}\n-----END PRIVATE KEY-----\n")
}
#[test]
fn a_planted_private_key_never_reaches_debug() {
let tls = TlsConfig::new()
.with_ca_certificate_pem("-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----")
.with_client_certificate_pem("cert", planted_key());
let rendered = format!("{tls:?}");
assert!(
!rendered.contains(PLANTED),
"the private key reached `Debug`: {rendered}"
);
assert!(
rendered.contains("<redacted>"),
"the key should be visibly withheld rather than absent: {rendered}"
);
}
#[test]
fn debug_prints_shape_and_never_material() {
let files = TlsConfig::new()
.with_ca_certificate_file("/etc/ssl/private-ca.pem")
.with_client_certificate_files("/etc/ssl/app.crt", "/etc/ssl/app.key");
let rendered = format!("{files:?}");
assert!(rendered.contains("/etc/ssl/private-ca.pem"), "{rendered}");
assert!(rendered.contains("/etc/ssl/app.key"), "{rendered}");
let bytes = TlsConfig::new().with_ca_certificate_pem("cert-material-here");
let rendered = format!("{bytes:?}");
assert!(rendered.contains("<pem bytes>"), "{rendered}");
assert!(
!rendered.contains("cert-material-here"),
"even a certificate's bytes are noise in a log: {rendered}"
);
}
#[test]
fn a_planted_private_key_never_reaches_a_read_error() {
let directory = std::env::temp_dir();
let tls = TlsConfig::new().with_client_certificate_files(&directory, &directory);
let error = tls
.client_certificate_pem("vault https://vault.internal path myapp/db")
.expect_err("a directory is not a PEM file");
let rendered = error.to_string();
assert!(!rendered.contains(PLANTED), "{rendered}");
assert!(rendered.contains("the client certificate"), "{rendered}");
assert!(
rendered.contains("vault https://vault.internal"),
"{rendered}"
);
}
#[test]
fn a_read_error_names_the_material_that_failed() {
let tls = TlsConfig::new().with_ca_certificate_file("/nonexistent/private-ca.pem");
let error = tls
.ca_certificate_pem("consul http://consul:8500 key myapp/db.json")
.expect_err("the file is not there");
assert!(error.to_string().contains("the CA certificate"), "{error}");
assert!(
error.to_string().contains("/nonexistent/private-ca.pem"),
"{error}"
);
}
#[test]
fn a_refusal_carries_the_description_it_was_given_and_adds_nothing() {
let error = unsupported(
"nats nats://***@nats.internal:4222 key db.json",
"a CA certificate from PEM bytes",
"name a file with `with_ca_certificate_file`",
);
let rendered = error.to_string();
assert!(
rendered.contains("nats://***@nats.internal:4222"),
"{rendered}"
);
assert!(!rendered.contains("hunter2"), "{rendered}");
assert!(
rendered.contains("refused rather than ignored"),
"{rendered}"
);
}
#[test]
fn bytes_and_a_file_resolve_to_the_same_material() {
let directory = std::env::temp_dir().join("dynamic-config-store-core-tls-test");
std::fs::create_dir_all(&directory).unwrap();
let path = directory.join("ca.pem");
std::fs::write(&path, b"-----BEGIN CERTIFICATE-----\nca\n").unwrap();
let from_file = TlsConfig::new().with_ca_certificate_file(&path);
let from_bytes =
TlsConfig::new().with_ca_certificate_pem(&b"-----BEGIN CERTIFICATE-----\nca\n"[..]);
assert_eq!(
from_file.ca_certificate_pem("store").unwrap(),
from_bytes.ca_certificate_pem("store").unwrap()
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn an_empty_configuration_is_the_platform_trust_store() {
assert!(TlsConfig::new().is_empty());
assert!(!TlsConfig::new().with_ca_certificate_pem("x").is_empty());
assert!(!TlsConfig::new()
.with_client_certificate_pem("c", "k")
.is_empty());
}
#[test]
fn a_path_is_offered_only_by_the_file_spelling() {
let files = TlsConfig::new().with_ca_certificate_file("/etc/ssl/ca.pem");
assert_eq!(
files.ca_certificate().and_then(Pem::path),
Some(Path::new("/etc/ssl/ca.pem"))
);
let bytes = TlsConfig::new().with_ca_certificate_pem("x");
assert_eq!(bytes.ca_certificate().and_then(Pem::path), None);
}
}