Skip to content

Commit

Permalink
Load certificate from cert file *and directory*
Browse files Browse the repository at this point in the history
• On Unix, certificates were loaded from the OpenSSL certificate
  store but only the file based-certificate store (known as CAfile)
  in OpenSSL. Load certs from dir-based store too (CAdir).
• On all platforms, accept new SSL_CERT_DIR, which is also accepted
  by OpenSSL.
  • Loading branch information
pgerber authored and djc committed Jul 2, 2024
1 parent b818726 commit a0a7012
Show file tree
Hide file tree
Showing 5 changed files with 286 additions and 17 deletions.
20 changes: 20 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 Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ ring = "0.17"
rustls = "0.23"
rustls-webpki = "0.102"
serial_test = "3"
tempfile = "3.5"
untrusted = "0.9"
webpki-roots = "0.26"
x509-parser = "0.16"
Expand Down
235 changes: 224 additions & 11 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ mod macos;
use macos as platform;

use std::env;
use std::fs::File;
use std::ffi::OsStr;
use std::fs::{self, File};
use std::io::BufReader;
use std::io::{Error, ErrorKind};
use std::path::{Path, PathBuf};
Expand All @@ -46,8 +47,16 @@ use pki_types::CertificateDer;

/// Load root certificates found in the platform's native certificate store.
///
/// If the SSL_CERT_FILE environment variable is set, certificates (in PEM
/// format) are read from that file instead.
/// ## Environment Variables
///
/// | Env. Var. | Description |
/// |----------------|---------------------------------------------------------------------------------------|
/// | SSL_CERT_FILE | File containing an arbitrary number of certificates in PEM format. |
/// | SSL_CERT_DIR | Directory utilizing the hierarchy and naming convention used by OpenSSL's [c_rehash]. |
///
/// If **either** (or **both**) are set, certificates are only loaded from
/// the locations specified via environment variables and not the platform-
/// native certificate store.
///
/// ## Certificate Validity
///
Expand Down Expand Up @@ -106,23 +115,65 @@ use pki_types::CertificateDer;
/// This function can be expensive: on some platforms it involves loading
/// and parsing a ~300KB disk file. It's therefore prudent to call
/// this sparingly.
///
/// [c_rehash]: https://www.openssl.org/docs/manmaster/man1/c_rehash.html
pub fn load_native_certs() -> Result<Vec<CertificateDer<'static>>, Error> {
load_certs_from_env().unwrap_or_else(platform::load_native_certs)
if let Some(certs) = load_certs_from_env()? {
return Ok(certs);
};
platform::load_native_certs()
}

const ENV_CERT_FILE: &str = "SSL_CERT_FILE";

/// Returns None if SSL_CERT_FILE is not defined in the current environment.
/// Returns certificates stored at SSL_CERT_FILE and/or SSL_CERT_DIR.
///
/// If neither is set, `None` is returned.
///
/// If it is defined, it is always used, so it must be a path to an existing,
/// If SSL_CERT_FILE is defined, it is always used, so it must be a path to an existing,
/// accessible file from which certificates can be loaded successfully. While parsing,
/// [rustls_pemfile::certs()] parser will ignore parts of the file which are
/// not considered part of a certificate. Certificates which are not in the right
/// format (PEM) or are otherwise corrupted may get ignored silently.
fn load_certs_from_env() -> Option<Result<Vec<CertificateDer<'static>>, Error>> {
let cert_var_path = PathBuf::from(env::var_os(ENV_CERT_FILE)?);
///
/// If SSL_CERT_DIR is defined, a directory must exist at this path, and all
/// [hash files](`is_hash_file_name()`) contained in it must be loaded successfully,
/// subject to the rules outlined above for SSL_CERT_FILE. The directory is not
/// scanned recursively and may be empty.
fn load_certs_from_env() -> Result<Option<Vec<CertificateDer<'static>>>, Error> {
let paths = CertPaths {
file: env::var_os(ENV_CERT_FILE).map(PathBuf::from),
dir: env::var_os(ENV_CERT_DIR).map(PathBuf::from),
};

Some(load_pem_certs(&cert_var_path))
Ok(match &paths {
CertPaths {
file: None,
dir: None,
} => None,
_ => Some(paths.load()?),
})
}

impl CertPaths {
fn load(&self) -> Result<Vec<CertificateDer<'static>>, Error> {
let mut certs = match &self.file {
Some(cert_file) => load_pem_certs(cert_file)?,
None => Vec::new(),
};

if let Some(cert_dir) = &self.dir {
certs.append(&mut load_pem_certs_from_dir(cert_dir)?);
}

certs.sort_unstable_by(|a, b| a.cmp(b));
certs.dedup();

Ok(certs)
}
}

struct CertPaths {
file: Option<PathBuf>,
dir: Option<PathBuf>,
}

fn load_pem_certs(path: &Path) -> Result<Vec<CertificateDer<'static>>, Error> {
Expand All @@ -138,10 +189,162 @@ fn load_pem_certs(path: &Path) -> Result<Vec<CertificateDer<'static>>, Error> {
.collect()
}

/// Load certificate from certificate directory (what OpenSSL calls CAdir)
///
/// This directory can contain other files and directories. CAfile tends
/// to be in here too. To avoid loading something twice or something that
/// isn't a valid certificate, we limit ourselves to loading those files
/// that have a hash-based file name matching the pattern used by OpenSSL.
/// The hash is not verified, however.
fn load_pem_certs_from_dir(dir: &Path) -> Result<Vec<CertificateDer<'static>>, Error> {
let dir_reader = fs::read_dir(dir)?;
let mut certs = Vec::new();
for entry in dir_reader {
let entry = entry?;
let path = entry.path();
let file_name = path
.file_name()
// We are looping over directory entries. Directory entries
// always have a name (except "." and ".." which the iterator
// never yields).
.expect("dir entry with no name");

// `openssl rehash` used to create this directory uses symlinks. So,
// make sure we resolve them.
let metadata = match fs::metadata(&path) {
Ok(metadata) => metadata,
Err(e) if e.kind() == ErrorKind::NotFound => {
// Dangling symlink
continue;
}
Err(e) => return Err(e),
};
if metadata.is_file() && is_hash_file_name(file_name) {
certs.append(&mut load_pem_certs(&path)?);
}
}
Ok(certs)
}

/// Check if this is a hash-based file name for a certificate
///
/// According to the [c_rehash man page][]:
///
/// > The links created are of the form HHHHHHHH.D, where each H is a hexadecimal
/// > character and D is a single decimal digit.
///
/// `c_rehash` generates lower-case hex digits but this is not clearly documented.
/// Because of this, and because it could lead to issues on case-insensitive file
/// systems, upper-case hex digits are accepted too.
///
/// [c_rehash man page]: https://www.openssl.org/docs/manmaster/man1/c_rehash.html
fn is_hash_file_name(file_name: &OsStr) -> bool {
let file_name = match file_name.to_str() {
Some(file_name) => file_name,
None => return false, // non-UTF8 can't be hex digits
};

if file_name.len() != 10 {
return false;
}
let mut iter = file_name.chars();
let iter = iter.by_ref();
iter.take(8)
.all(|c| c.is_ascii_hexdigit())
&& iter.next() == Some('.')
&& matches!(iter.next(), Some(c) if c.is_ascii_digit())
}

const ENV_CERT_FILE: &str = "SSL_CERT_FILE";
const ENV_CERT_DIR: &str = "SSL_CERT_DIR";

#[cfg(test)]
mod tests {
use super::*;

use std::io::Write;

#[test]
fn valid_hash_file_name() {
let valid_names = [
"f3377b1b.0",
"e73d606e.1",
"01234567.2",
"89abcdef.3",
"ABCDEF00.9",
];
for name in valid_names {
assert!(is_hash_file_name(OsStr::new(name)));
}
}

#[test]
fn invalid_hash_file_name() {
let valid_names = [
"f3377b1b.a",
"e73d606g.1",
"0123457.2",
"89abcdef0.3",
"name.pem",
];
for name in valid_names {
assert!(!is_hash_file_name(OsStr::new(name)));
}
}

#[test]
fn deduplication() {
let temp_dir = tempfile::TempDir::new().unwrap();
let cert1 = include_str!("../tests/badssl-com-chain.pem");
let cert2 = include_str!("../integration-tests/one-existing-ca.pem");
let file_path = temp_dir
.path()
.join("ca-certificates.crt");
let dir_path = temp_dir.path().to_path_buf();

{
let mut file = File::create(&file_path).unwrap();
write!(file, "{}", &cert1).unwrap();
write!(file, "{}", &cert2).unwrap();
}

{
// Duplicate (already in `file_path`)
let mut file = File::create(dir_path.join("71f3bb26.0")).unwrap();
write!(file, "{}", &cert1).unwrap();
}

{
// Duplicate (already in `file_path`)
let mut file = File::create(dir_path.join("912e7cd5.0")).unwrap();
write!(file, "{}", &cert2).unwrap();
}

let certs_from_file = CertPaths {
file: Some(file_path.clone()),
dir: None,
}
.load()
.unwrap();
assert_eq!(certs_from_file.len(), 2);

let certs_from_dir = CertPaths {
file: None,
dir: Some(dir_path.clone()),
}
.load()
.unwrap();
assert_eq!(certs_from_dir.len(), 2);

let certs_from_both = CertPaths {
file: Some(file_path),
dir: Some(dir_path),
}
.load()
.unwrap();
assert_eq!(certs_from_both.len(), 2);
}

#[test]
fn malformed_file_from_env() {
// Certificate parser tries to extract certs from file ignoring
Expand All @@ -160,6 +363,16 @@ mod tests {
);
}

#[test]
fn from_env_missing_dir() {
assert_eq!(
load_pem_certs_from_dir(Path::new("no/such/directory"))
.unwrap_err()
.kind(),
ErrorKind::NotFound
);
}

#[test]
#[cfg(unix)]
fn from_env_with_non_regular_and_empty_file() {
Expand Down
10 changes: 5 additions & 5 deletions src/unix.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
use crate::load_pem_certs;
use crate::CertPaths;

use pki_types::CertificateDer;

use std::io::Error;

pub fn load_native_certs() -> Result<Vec<CertificateDer<'static>>, Error> {
let likely_locations = openssl_probe::probe();

match likely_locations.cert_file {
Some(cert_file) => load_pem_certs(&cert_file),
None => Ok(Vec::new()),
CertPaths {
file: likely_locations.cert_file,
dir: likely_locations.cert_dir,
}
.load()
}
37 changes: 36 additions & 1 deletion tests/smoketests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use std::io::{ErrorKind, Read, Write};
use std::net::TcpStream;
use std::path::PathBuf;
#[cfg(unix)]
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::{env, panic};

Expand Down Expand Up @@ -109,3 +111,36 @@ fn badssl_with_env() {
check_site("self-signed.badssl.com").unwrap();
env::remove_var("SSL_CERT_FILE");
}

#[test]
#[serial]
fn badssl_with_dir_from_env() {
let temp_dir = tempfile::TempDir::new().unwrap();
let original = Path::new("tests/badssl-com-chain.pem")
.canonicalize()
.unwrap();
let link1 = temp_dir.path().join("5d30f3c5.3");
#[cfg(unix)]
let link2 = temp_dir.path().join("fd3003c5.0");

env::set_var(
"SSL_CERT_DIR",
// The CA cert, downloaded directly from the site itself:
temp_dir.path(),
);
assert!(check_site("self-signed.badssl.com").is_err());

// OpenSSL uses symlinks too. So, use one for testing too, if possible.
#[cfg(unix)]
symlink(original, link1).unwrap();
#[cfg(not(unix))]
std::fs::copy(original, link1).unwrap();

// Dangling symlink
#[cfg(unix)]
symlink("/a/path/which/does/not/exist/hopefully", link2).unwrap();

check_site("self-signed.badssl.com").unwrap();

env::remove_var("SSL_CERT_DIR");
}

0 comments on commit a0a7012

Please sign in to comment.