1use std::fs;
15use std::path::{Path, PathBuf};
16
17use rustls_pki_types::{
18 CertificateDer, PrivateKeyDer, PrivatePkcs1KeyDer, PrivatePkcs8KeyDer, PrivateSec1KeyDer,
19};
20use tracing::warn;
21use x509_parser::pem::Pem;
22
23const CERTIFICATE: &str = "CERTIFICATE";
25
26pub fn read_certificates(path: &Path) -> anyhow::Result<Vec<CertificateDer<'static>>> {
32 let bytes = read_file(path)?;
33
34 let mut chain = Vec::new();
35 for block in Pem::iter_from_buffer(&bytes) {
36 let block = block.map_err(|error| anyhow::anyhow!("{}: {error}", path.display()))?;
37 if block.label == CERTIFICATE {
38 chain.push(CertificateDer::from(block.contents));
39 }
40 }
41
42 if chain.is_empty() {
43 anyhow::bail!("{}: no CERTIFICATE block found", path.display());
44 }
45 Ok(chain)
46}
47
48pub fn read_private_key(path: &Path) -> anyhow::Result<PrivateKeyDer<'static>> {
56 let bytes = read_file(path)?;
57
58 let mut skipped: Vec<String> = Vec::new();
59 for block in Pem::iter_from_buffer(&bytes) {
60 let block = block.map_err(|error| anyhow::anyhow!("{}: {error}", path.display()))?;
61 let key = match block.label.as_str() {
62 "PRIVATE KEY" => PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(block.contents)),
63 "EC PRIVATE KEY" => PrivateKeyDer::Sec1(PrivateSec1KeyDer::from(block.contents)),
64 "RSA PRIVATE KEY" => PrivateKeyDer::Pkcs1(PrivatePkcs1KeyDer::from(block.contents)),
65 other => {
67 skipped.push(other.to_string());
68 continue;
69 }
70 };
71 return Ok(key);
72 }
73
74 if skipped.is_empty() {
75 anyhow::bail!("{}: no private key block found", path.display());
76 }
77 anyhow::bail!(
78 "{}: no private key block found, only {}",
79 path.display(),
80 skipped.join(", ")
81 );
82}
83
84pub fn write_private_key(path: &Path, pem: &str) -> anyhow::Result<()> {
91 use std::io::Write;
92
93 let mut options = fs::OpenOptions::new();
94 options.write(true).create_new(true);
95 #[cfg(unix)]
96 {
97 use std::os::unix::fs::OpenOptionsExt;
98 options.mode(0o600);
99 }
100 let mut file = options.open(path)?;
101 file.write_all(pem.as_bytes())?;
102 file.sync_all()?;
103 Ok(())
104}
105
106pub fn write_atomic(path: &Path, bytes: &[u8], mode: u32) -> anyhow::Result<()> {
138 use std::io::Write;
139
140 let temp = {
141 let mut temp = path.as_os_str().to_owned();
142 temp.push(format!(".{}.tmp", std::process::id()));
143 PathBuf::from(temp)
144 };
145 let _ = fs::remove_file(&temp);
169 let mut options = fs::OpenOptions::new();
170 options.write(true).create_new(true);
171 #[cfg(unix)]
172 {
173 use std::os::unix::fs::OpenOptionsExt;
174 options.mode(mode);
175 }
176 let mut file = options.open(&temp)?;
177 file.write_all(bytes)?;
178 file.sync_all()?;
180 drop(file);
181
182 fs::rename(&temp, path)?;
183 Ok(())
184}
185
186pub fn warn_if_key_is_readable(event: &'static str, path: &Path) {
197 #[cfg(unix)]
198 {
199 use std::os::unix::fs::PermissionsExt;
200 if let Ok(metadata) = fs::metadata(path) {
201 let mode = metadata.permissions().mode() & 0o077;
202 if mode != 0 {
203 warn!(event,
204 outcome = "advisory",
205 file_path = ?path,
206 mode = format!("{:o}", metadata.permissions().mode() & 0o777),
207 "private key is readable beyond its owner");
208 }
209 }
210 }
211 #[cfg(not(unix))]
212 let _ = (event, path);
213}
214
215fn read_file(path: &Path) -> anyhow::Result<Vec<u8>> {
217 fs::read(path).map_err(|error| anyhow::anyhow!("{}: {error}", path.display()))
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::testutil::TempDir;
224
225 fn certificate() -> (String, rcgen::KeyPair) {
227 let key_pair = rcgen::KeyPair::generate().unwrap();
228 let params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap();
229 let cert = params.self_signed(&key_pair).unwrap();
230 (cert.pem(), key_pair)
231 }
232
233 #[test]
236 fn every_certificate_block_is_read_in_order() {
237 let dir = TempDir::new("pemfile");
238 let (leaf, _) = certificate();
239 let (ca, _) = certificate();
240 let path = dir.write("chain.pem", &format!("{leaf}{ca}"));
241
242 let chain = read_certificates(&path).unwrap();
243 assert_eq!(chain.len(), 2);
244 let expected = read_certificates(&dir.write("leaf.pem", &leaf)).unwrap();
246 assert_eq!(chain[0], expected[0]);
247 assert_ne!(chain[0], chain[1]);
248 }
249
250 #[test]
252 fn a_key_block_is_skipped_when_reading_certificates() {
253 let dir = TempDir::new("pemfile");
254 let (pem, key_pair) = certificate();
255 let path = dir.write(
256 "both.pem",
257 &format!("{}{pem}", key_pair.serialize_pem()).to_string(),
258 );
259
260 assert_eq!(read_certificates(&path).unwrap().len(), 1);
261 }
262
263 #[test]
264 fn a_file_without_a_certificate_is_an_error() {
265 let dir = TempDir::new("pemfile");
266 let path = dir.write("empty.pem", "not pem at all\n");
267
268 let error = read_certificates(&path).unwrap_err().to_string();
269 assert!(error.contains("no CERTIFICATE block"), "{error}");
270 assert!(error.contains("empty.pem"), "{error}");
271 }
272
273 #[test]
274 fn a_missing_file_names_itself() {
275 let dir = TempDir::new("pemfile");
276 let path = dir.join("absent.pem");
277
278 let error = read_certificates(&path).unwrap_err().to_string();
279 assert!(error.contains("absent.pem"), "{error}");
280 }
281
282 #[test]
285 fn each_private_key_label_maps_to_its_encoding() {
286 let dir = TempDir::new("pemfile");
287 let (_, key_pair) = certificate();
288 let der = key_pair.serialize_der();
289 let body = base64_pem(&der);
290
291 for (label, expected) in [
292 ("PRIVATE KEY", "Pkcs8"),
293 ("EC PRIVATE KEY", "Sec1"),
294 ("RSA PRIVATE KEY", "Pkcs1"),
295 ] {
296 let path = dir.write(
297 "key.pem",
298 &format!("-----BEGIN {label}-----\n{body}-----END {label}-----\n"),
299 );
300 let key = read_private_key(&path).unwrap();
301 let variant = match key {
302 PrivateKeyDer::Pkcs8(_) => "Pkcs8",
303 PrivateKeyDer::Sec1(_) => "Sec1",
304 PrivateKeyDer::Pkcs1(_) => "Pkcs1",
305 _ => "unknown",
306 };
307 assert_eq!(variant, expected, "label {label}");
308 fs::remove_file(&path).unwrap();
309 }
310 }
311
312 #[test]
315 fn a_certificate_where_a_key_is_expected_names_the_label() {
316 let dir = TempDir::new("pemfile");
317 let (pem, _) = certificate();
318 let path = dir.write("swapped.pem", &pem);
319
320 let error = read_private_key(&path).unwrap_err().to_string();
321 assert!(error.contains("no private key block"), "{error}");
322 assert!(error.contains("CERTIFICATE"), "{error}");
323 }
324
325 #[test]
326 fn a_file_without_any_block_is_an_error() {
327 let dir = TempDir::new("pemfile");
328 let path = dir.write("garbage.key", "-- nothing here --\n");
329
330 let error = read_private_key(&path).unwrap_err().to_string();
331 assert!(error.contains("no private key block"), "{error}");
332 }
333
334 #[test]
336 #[cfg(unix)]
337 fn a_written_key_is_owner_only() {
338 use std::os::unix::fs::PermissionsExt;
339
340 let dir = TempDir::new("pemfile");
341 let path = dir.join("written.key");
342 write_private_key(&path, "-----BEGIN PRIVATE KEY-----\n").unwrap();
343
344 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
345 assert_eq!(mode, 0o600, "mode was {mode:o}");
346 assert!(write_private_key(&path, "x").is_err());
348 }
349
350 fn base64_pem(der: &[u8]) -> String {
352 use base64::prelude::*;
353
354 let encoded = BASE64_STANDARD.encode(der);
355 let mut out = String::new();
356 for chunk in encoded.as_bytes().chunks(64) {
357 out.push_str(std::str::from_utf8(chunk).unwrap());
358 out.push('\n');
359 }
360 out
361 }
362
363 #[test]
364 fn write_atomic_replaces_the_target_and_leaves_no_temporary() {
365 let dir = TempDir::new("pemfile");
366 let path = dir.join("ledger.json");
367
368 write_atomic(&path, b"first", 0o600).unwrap();
369 assert_eq!(fs::read(&path).unwrap(), b"first");
370
371 write_atomic(&path, b"second", 0o600).unwrap();
374 assert_eq!(fs::read(&path).unwrap(), b"second");
375
376 assert!(
377 !path.with_extension("tmp").exists(),
378 "the temporary must be renamed away, not left behind"
379 );
380 }
381
382 #[cfg(unix)]
383 #[test]
384 fn write_atomic_honours_the_requested_mode() {
385 use std::os::unix::fs::PermissionsExt;
386 let dir = TempDir::new("pemfile");
387
388 let private = dir.join("ledger.json");
389 write_atomic(&private, b"{}", 0o600).unwrap();
390 assert_eq!(
391 fs::metadata(&private).unwrap().permissions().mode() & 0o777,
392 0o600,
393 "the revocation ledger decides what the CRL says; it is not public"
394 );
395
396 let public = dir.join("ca.crl");
397 write_atomic(&public, b"der", 0o644).unwrap();
398 assert_eq!(
399 fs::metadata(&public).unwrap().permissions().mode() & 0o777,
400 0o644,
401 "the CRL is published material and is served to anyone"
402 );
403 }
404
405 fn scratch_of(path: &Path) -> PathBuf {
413 let mut temp = path.as_os_str().to_owned();
414 temp.push(format!(".{}.tmp", std::process::id()));
415 PathBuf::from(temp)
416 }
417
418 #[test]
422 fn write_atomic_replaces_a_stale_temporary() {
423 let dir = TempDir::new("pemfile");
424 let path = dir.join("ledger.json");
425 let scratch = scratch_of(&path);
426 fs::write(&scratch, b"leftover from a crash").unwrap();
427
428 write_atomic(&path, b"fresh", 0o600).unwrap();
429 assert_eq!(fs::read(&path).unwrap(), b"fresh");
430 assert!(!scratch.exists(), "the scratch file must be renamed away");
431 }
432
433 #[cfg(unix)]
441 #[test]
442 fn a_stale_temporary_does_not_decide_the_mode() {
443 use std::os::unix::fs::PermissionsExt;
444
445 let dir = TempDir::new("pemfile");
446 let path = dir.join("ledger.json");
447 let scratch = scratch_of(&path);
448 fs::write(&scratch, b"leftover").unwrap();
449 fs::set_permissions(&scratch, fs::Permissions::from_mode(0o666)).unwrap();
450
451 write_atomic(&path, b"{}", 0o600).unwrap();
452 assert_eq!(
453 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
454 0o600,
455 "the requested mode must win over the leftover file's"
456 );
457 }
458
459 #[cfg(unix)]
467 #[test]
468 fn a_symlink_at_the_scratch_path_does_not_redirect_the_write() {
469 let dir = TempDir::new("pemfile");
470 let path = dir.join("ca.crl");
471 let elsewhere = dir.join("attacker-readable");
472 fs::write(&elsewhere, b"untouched").unwrap();
473 std::os::unix::fs::symlink(&elsewhere, scratch_of(&path)).unwrap();
474
475 write_atomic(&path, b"the real CRL", 0o644).unwrap();
476
477 assert_eq!(fs::read(&path).unwrap(), b"the real CRL");
478 assert_eq!(
479 fs::read(&elsewhere).unwrap(),
480 b"untouched",
481 "the write followed the symlink"
482 );
483 }
484}