use std::fmt;
use std::fs::{File, Metadata};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::server::WebPkiClientVerifier;
use rustls::RootCertStore;
use crate::config::TlsConfig;
pub struct Tls {
config: Arc<rustls::ServerConfig>,
mutual: bool,
}
impl Tls {
pub fn load(config: &TlsConfig) -> Result<Self, TlsError> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let chain = read_certificates(Path::new(&config.certificate), Role::Certificate)?;
let key = read_private_key(Path::new(&config.key))?;
let verifier = match &config.client_ca {
Some(authority) => {
let mut roots = RootCertStore::empty();
for certificate in read_certificates(Path::new(authority), Role::ClientCa)? {
roots
.add(certificate)
.map_err(|source| TlsError::UnusableClientCa {
path: PathBuf::from(authority),
source,
})?;
}
WebPkiClientVerifier::builder_with_provider(Arc::new(roots), Arc::clone(&provider))
.build()
.map_err(|_| TlsError::UnusableClientCa {
path: PathBuf::from(authority),
source: rustls::Error::General(
"it contains no usable certificate authority".to_owned(),
),
})?
}
None => WebPkiClientVerifier::no_client_auth(),
};
let mutual = config.client_ca.is_some();
let mut server = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|_| TlsError::Provider)?
.with_client_cert_verifier(verifier)
.with_single_cert(chain, key)
.map_err(|_| TlsError::KeyDoesNotMatch {
certificate: PathBuf::from(&config.certificate),
key: PathBuf::from(&config.key),
})?;
server.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(Self {
config: Arc::new(server),
mutual,
})
}
#[must_use]
pub fn server_config(&self) -> Arc<rustls::ServerConfig> {
Arc::clone(&self.config)
}
#[must_use]
pub fn is_mutual(&self) -> bool {
self.mutual
}
}
impl fmt::Debug for Tls {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Tls")
.field("mutual", &self.mutual)
.field("alpn", &"http/1.1")
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Role {
Certificate,
ClientCa,
}
impl Role {
fn key(self) -> &'static str {
match self {
Self::Certificate => "certificate",
Self::ClientCa => "client_ca",
}
}
}
#[cfg(unix)]
const FORBIDDEN_BITS: u32 = 0o077;
fn read_certificates(path: &Path, role: Role) -> Result<Vec<CertificateDer<'static>>, TlsError> {
let mut certificates = Vec::new();
for certificate in
CertificateDer::pem_file_iter(path).map_err(|source| unreadable(path, role, &source))?
{
certificates.push(certificate.map_err(|_| TlsError::UnusablePem {
path: path.to_owned(),
key: role.key(),
})?);
}
if certificates.is_empty() {
return Err(TlsError::NoCertificates {
path: path.to_owned(),
key: role.key(),
});
}
Ok(certificates)
}
fn read_private_key(path: &Path) -> Result<PrivateKeyDer<'static>, TlsError> {
let mut file = File::open(path).map_err(|source| TlsError::Unreadable {
path: path.to_owned(),
key: "key",
source,
})?;
let metadata = file.metadata().map_err(|source| TlsError::Unreadable {
path: path.to_owned(),
key: "key",
source,
})?;
refuse_permissive(path, &metadata)?;
let mut pem = Vec::new();
file.read_to_end(&mut pem)
.map_err(|source| TlsError::Unreadable {
path: path.to_owned(),
key: "key",
source,
})?;
let key = PrivateKeyDer::from_pem_slice(&pem).map_err(|_| TlsError::UnusableKey {
path: path.to_owned(),
});
drop(pem);
key
}
#[cfg(unix)]
fn refuse_permissive(path: &Path, metadata: &Metadata) -> Result<(), TlsError> {
use std::os::unix::fs::PermissionsExt as _;
let mode = metadata.permissions().mode() & 0o777;
if mode & FORBIDDEN_BITS == 0 {
return Ok(());
}
Err(TlsError::PermissiveKey {
path: path.to_owned(),
mode,
})
}
#[cfg(not(unix))]
fn refuse_permissive(_path: &Path, _metadata: &Metadata) -> Result<(), TlsError> {
Ok(())
}
fn unreadable(path: &Path, role: Role, source: &rustls::pki_types::pem::Error) -> TlsError {
match source {
rustls::pki_types::pem::Error::Io(error) => TlsError::Unreadable {
path: path.to_owned(),
key: role.key(),
source: std::io::Error::new(error.kind(), error.to_string()),
},
_ => TlsError::UnusablePem {
path: path.to_owned(),
key: role.key(),
},
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum TlsError {
Unreadable {
path: PathBuf,
key: &'static str,
source: std::io::Error,
},
PermissiveKey {
path: PathBuf,
mode: u32,
},
UnusablePem {
path: PathBuf,
key: &'static str,
},
UnusableKey {
path: PathBuf,
},
NoCertificates {
path: PathBuf,
key: &'static str,
},
UnusableClientCa {
path: PathBuf,
source: rustls::Error,
},
KeyDoesNotMatch {
certificate: PathBuf,
key: PathBuf,
},
Provider,
}
impl fmt::Display for TlsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unreadable { path, key, source } => write!(
f,
"`tls.{key}` names `{}`, which cannot be read: {source}",
path.display()
),
Self::PermissiveKey { path, mode } => write!(
f,
"the private key `{}` is mode {mode:04o}, which lets an account other than its \
owner read it; a key anybody on the host can read is not a key. `chmod 600` it \
— or, on Kubernetes, mount the secret with `defaultMode: 0400`",
path.display()
),
Self::UnusablePem { path, key } => write!(
f,
"`tls.{key}` names `{}`, which is not PEM this build can parse",
path.display()
),
Self::UnusableKey { path } => write!(
f,
"`tls.key` names `{}`, which holds no PKCS#8, PKCS#1 or SEC1 private key. The \
file's contents are deliberately not quoted here",
path.display()
),
Self::NoCertificates { path, key } => write!(
f,
"`tls.{key}` names `{}`, which contains no certificate",
path.display()
),
Self::UnusableClientCa { path, source } => write!(
f,
"`tls.client_ca` names `{}`, which cannot be a trust anchor: {source}",
path.display()
),
Self::KeyDoesNotMatch { certificate, key } => write!(
f,
"the private key `{}` is not the key of the certificate `{}`, or is of a type \
this build cannot sign with",
key.display(),
certificate.display()
),
Self::Provider => f.write_str(
"the `ring` cryptography provider does not support rustls's default protocol \
versions, which is a build problem rather than a configuration one",
),
}
}
}
impl std::error::Error for TlsError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Unreadable { source, .. } => Some(source),
Self::UnusableClientCa { source, .. } => Some(source),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const PLANTED: &str =
"-----BEGIN PRIVATE KEY-----\nPLANTED-KEY-MATERIAL\n-----END PRIVATE KEY-----\n";
fn written(name: &str, contents: &str, mode: u32) -> (tempfile::TempDir, PathBuf) {
let directory = tempfile::tempdir().expect("a temporary directory");
let path = directory.path().join(name);
std::fs::write(&path, contents).expect("writable");
chmod(&path, mode);
(directory, path)
}
#[cfg(unix)]
fn chmod(path: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).expect("chmod");
}
#[cfg(not(unix))]
fn chmod(_path: &Path, _mode: u32) {}
#[cfg(unix)]
#[test]
fn a_key_anybody_can_read_is_refused_and_the_message_names_the_fix() {
let (_directory, path) = written("key.pem", PLANTED, 0o644);
let error = read_private_key(&path).expect_err("mode 644 is not a secret");
assert!(
matches!(error, TlsError::PermissiveKey { mode: 0o644, .. }),
"{error:?}"
);
assert!(error.to_string().contains("chmod 600"), "{error}");
}
#[cfg(unix)]
#[test]
fn a_key_only_its_owner_can_read_passes_the_permission_check() {
let (_directory, path) = written("key.pem", PLANTED, 0o600);
let error = read_private_key(&path).expect_err("the planted key is not a key");
assert!(matches!(error, TlsError::UnusableKey { .. }), "{error:?}");
}
#[test]
fn no_error_about_a_key_file_carries_the_key() {
let (_directory, path) = written("key.pem", PLANTED, 0o644);
let missing = read_private_key(Path::new("/nonexistent/key.pem")).unwrap_err();
let permissive = read_private_key(&path).unwrap_err();
chmod(&path, 0o600);
let unusable = read_private_key(&path).unwrap_err();
for error in [missing, permissive, unusable] {
let rendered = format!("{error} / {error:?}");
assert!(
!rendered.contains("PLANTED-KEY-MATERIAL"),
"a private key escaped through an error: {rendered}"
);
}
}
#[test]
fn a_certificate_file_that_is_not_pem_is_refused_without_being_quoted() {
let (_directory, path) = written("cert.pem", "not a certificate: hunter2\n", 0o644);
let error = read_certificates(&path, Role::Certificate).unwrap_err();
let rendered = format!("{error} / {error:?}");
assert!(rendered.contains("tls.certificate"), "{rendered}");
assert!(!rendered.contains("hunter2"), "{rendered}");
}
}