use std::fs;
use std::path::{Path, PathBuf};
use rustls_pki_types::{
CertificateDer, PrivateKeyDer, PrivatePkcs1KeyDer, PrivatePkcs8KeyDer, PrivateSec1KeyDer,
};
use tracing::warn;
use x509_parser::pem::Pem;
const CERTIFICATE: &str = "CERTIFICATE";
pub fn read_certificates(path: &Path) -> anyhow::Result<Vec<CertificateDer<'static>>> {
let bytes = read_file(path)?;
let mut chain = Vec::new();
for block in Pem::iter_from_buffer(&bytes) {
let block = block.map_err(|error| anyhow::anyhow!("{}: {error}", path.display()))?;
if block.label == CERTIFICATE {
chain.push(CertificateDer::from(block.contents));
}
}
if chain.is_empty() {
anyhow::bail!("{}: no CERTIFICATE block found", path.display());
}
Ok(chain)
}
pub fn read_private_key(path: &Path) -> anyhow::Result<PrivateKeyDer<'static>> {
let bytes = read_file(path)?;
let mut skipped: Vec<String> = Vec::new();
for block in Pem::iter_from_buffer(&bytes) {
let block = block.map_err(|error| anyhow::anyhow!("{}: {error}", path.display()))?;
let key = match block.label.as_str() {
"PRIVATE KEY" => PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(block.contents)),
"EC PRIVATE KEY" => PrivateKeyDer::Sec1(PrivateSec1KeyDer::from(block.contents)),
"RSA PRIVATE KEY" => PrivateKeyDer::Pkcs1(PrivatePkcs1KeyDer::from(block.contents)),
other => {
skipped.push(other.to_string());
continue;
}
};
return Ok(key);
}
if skipped.is_empty() {
anyhow::bail!("{}: no private key block found", path.display());
}
anyhow::bail!(
"{}: no private key block found, only {}",
path.display(),
skipped.join(", ")
);
}
pub fn write_private_key(path: &Path, pem: &str) -> anyhow::Result<()> {
use std::io::Write;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path)?;
file.write_all(pem.as_bytes())?;
file.sync_all()?;
Ok(())
}
pub fn write_atomic(path: &Path, bytes: &[u8], mode: u32) -> anyhow::Result<()> {
use std::io::Write;
let temp = {
let mut temp = path.as_os_str().to_owned();
temp.push(format!(".{}.tmp", std::process::id()));
PathBuf::from(temp)
};
let _ = fs::remove_file(&temp);
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(mode);
}
let mut file = options.open(&temp)?;
file.write_all(bytes)?;
file.sync_all()?;
drop(file);
fs::rename(&temp, path)?;
Ok(())
}
pub fn warn_if_key_is_readable(event: &'static str, path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = fs::metadata(path) {
let mode = metadata.permissions().mode() & 0o077;
if mode != 0 {
warn!(event,
outcome = "advisory",
file_path = ?path,
mode = format!("{:o}", metadata.permissions().mode() & 0o777),
"private key is readable beyond its owner");
}
}
}
#[cfg(not(unix))]
let _ = (event, path);
}
fn read_file(path: &Path) -> anyhow::Result<Vec<u8>> {
fs::read(path).map_err(|error| anyhow::anyhow!("{}: {error}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::TempDir;
fn certificate() -> (String, rcgen::KeyPair) {
let key_pair = rcgen::KeyPair::generate().unwrap();
let params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap();
let cert = params.self_signed(&key_pair).unwrap();
(cert.pem(), key_pair)
}
#[test]
fn every_certificate_block_is_read_in_order() {
let dir = TempDir::new("pemfile");
let (leaf, _) = certificate();
let (ca, _) = certificate();
let path = dir.write("chain.pem", &format!("{leaf}{ca}"));
let chain = read_certificates(&path).unwrap();
assert_eq!(chain.len(), 2);
let expected = read_certificates(&dir.write("leaf.pem", &leaf)).unwrap();
assert_eq!(chain[0], expected[0]);
assert_ne!(chain[0], chain[1]);
}
#[test]
fn a_key_block_is_skipped_when_reading_certificates() {
let dir = TempDir::new("pemfile");
let (pem, key_pair) = certificate();
let path = dir.write(
"both.pem",
&format!("{}{pem}", key_pair.serialize_pem()).to_string(),
);
assert_eq!(read_certificates(&path).unwrap().len(), 1);
}
#[test]
fn a_file_without_a_certificate_is_an_error() {
let dir = TempDir::new("pemfile");
let path = dir.write("empty.pem", "not pem at all\n");
let error = read_certificates(&path).unwrap_err().to_string();
assert!(error.contains("no CERTIFICATE block"), "{error}");
assert!(error.contains("empty.pem"), "{error}");
}
#[test]
fn a_missing_file_names_itself() {
let dir = TempDir::new("pemfile");
let path = dir.join("absent.pem");
let error = read_certificates(&path).unwrap_err().to_string();
assert!(error.contains("absent.pem"), "{error}");
}
#[test]
fn each_private_key_label_maps_to_its_encoding() {
let dir = TempDir::new("pemfile");
let (_, key_pair) = certificate();
let der = key_pair.serialize_der();
let body = base64_pem(&der);
for (label, expected) in [
("PRIVATE KEY", "Pkcs8"),
("EC PRIVATE KEY", "Sec1"),
("RSA PRIVATE KEY", "Pkcs1"),
] {
let path = dir.write(
"key.pem",
&format!("-----BEGIN {label}-----\n{body}-----END {label}-----\n"),
);
let key = read_private_key(&path).unwrap();
let variant = match key {
PrivateKeyDer::Pkcs8(_) => "Pkcs8",
PrivateKeyDer::Sec1(_) => "Sec1",
PrivateKeyDer::Pkcs1(_) => "Pkcs1",
_ => "unknown",
};
assert_eq!(variant, expected, "label {label}");
fs::remove_file(&path).unwrap();
}
}
#[test]
fn a_certificate_where_a_key_is_expected_names_the_label() {
let dir = TempDir::new("pemfile");
let (pem, _) = certificate();
let path = dir.write("swapped.pem", &pem);
let error = read_private_key(&path).unwrap_err().to_string();
assert!(error.contains("no private key block"), "{error}");
assert!(error.contains("CERTIFICATE"), "{error}");
}
#[test]
fn a_file_without_any_block_is_an_error() {
let dir = TempDir::new("pemfile");
let path = dir.write("garbage.key", "-- nothing here --\n");
let error = read_private_key(&path).unwrap_err().to_string();
assert!(error.contains("no private key block"), "{error}");
}
#[test]
#[cfg(unix)]
fn a_written_key_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new("pemfile");
let path = dir.join("written.key");
write_private_key(&path, "-----BEGIN PRIVATE KEY-----\n").unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "mode was {mode:o}");
assert!(write_private_key(&path, "x").is_err());
}
fn base64_pem(der: &[u8]) -> String {
use base64::prelude::*;
let encoded = BASE64_STANDARD.encode(der);
let mut out = String::new();
for chunk in encoded.as_bytes().chunks(64) {
out.push_str(std::str::from_utf8(chunk).unwrap());
out.push('\n');
}
out
}
#[test]
fn write_atomic_replaces_the_target_and_leaves_no_temporary() {
let dir = TempDir::new("pemfile");
let path = dir.join("ledger.json");
write_atomic(&path, b"first", 0o600).unwrap();
assert_eq!(fs::read(&path).unwrap(), b"first");
write_atomic(&path, b"second", 0o600).unwrap();
assert_eq!(fs::read(&path).unwrap(), b"second");
assert!(
!path.with_extension("tmp").exists(),
"the temporary must be renamed away, not left behind"
);
}
#[cfg(unix)]
#[test]
fn write_atomic_honours_the_requested_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new("pemfile");
let private = dir.join("ledger.json");
write_atomic(&private, b"{}", 0o600).unwrap();
assert_eq!(
fs::metadata(&private).unwrap().permissions().mode() & 0o777,
0o600,
"the revocation ledger decides what the CRL says; it is not public"
);
let public = dir.join("ca.crl");
write_atomic(&public, b"der", 0o644).unwrap();
assert_eq!(
fs::metadata(&public).unwrap().permissions().mode() & 0o777,
0o644,
"the CRL is published material and is served to anyone"
);
}
fn scratch_of(path: &Path) -> PathBuf {
let mut temp = path.as_os_str().to_owned();
temp.push(format!(".{}.tmp", std::process::id()));
PathBuf::from(temp)
}
#[test]
fn write_atomic_replaces_a_stale_temporary() {
let dir = TempDir::new("pemfile");
let path = dir.join("ledger.json");
let scratch = scratch_of(&path);
fs::write(&scratch, b"leftover from a crash").unwrap();
write_atomic(&path, b"fresh", 0o600).unwrap();
assert_eq!(fs::read(&path).unwrap(), b"fresh");
assert!(!scratch.exists(), "the scratch file must be renamed away");
}
#[cfg(unix)]
#[test]
fn a_stale_temporary_does_not_decide_the_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new("pemfile");
let path = dir.join("ledger.json");
let scratch = scratch_of(&path);
fs::write(&scratch, b"leftover").unwrap();
fs::set_permissions(&scratch, fs::Permissions::from_mode(0o666)).unwrap();
write_atomic(&path, b"{}", 0o600).unwrap();
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600,
"the requested mode must win over the leftover file's"
);
}
#[cfg(unix)]
#[test]
fn a_symlink_at_the_scratch_path_does_not_redirect_the_write() {
let dir = TempDir::new("pemfile");
let path = dir.join("ca.crl");
let elsewhere = dir.join("attacker-readable");
fs::write(&elsewhere, b"untouched").unwrap();
std::os::unix::fs::symlink(&elsewhere, scratch_of(&path)).unwrap();
write_atomic(&path, b"the real CRL", 0o644).unwrap();
assert_eq!(fs::read(&path).unwrap(), b"the real CRL");
assert_eq!(
fs::read(&elsewhere).unwrap(),
b"untouched",
"the write followed the symlink"
);
}
}