Skip to main content

acme_proxy_core/
pemfile.rs

1//! PEM material on disk: reading a certificate chain or a private key, and
2//! writing a key that is never briefly world-readable.
3//!
4//! Two subsystems provision key material at startup — `signer::local_ca`
5//! generates and reloads the CA, `tls` does the same for the HTTPS
6//! listener's certificate — so the file hygiene lives here rather than in either
7//! of them, the way `dns` holds the resolver that both `filter` and
8//! `challenge` need.
9//!
10//! Reading goes through `x509-parser`'s label-agnostic PEM iterator, already in
11//! the tree via `rcgen`. That is why `rustls-pemfile` is not a dependency: what
12//! it would buy is the label match below.
13
14use 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
23/// PEM label of an X.509 certificate.
24const CERTIFICATE: &str = "CERTIFICATE";
25
26/// Reads every `CERTIFICATE` block of `path`, in file order — which for a chain
27/// means leaf first, as `rustls` expects.
28///
29/// Blocks with any other label are skipped, so a file holding both the chain and
30/// its key is read correctly.
31pub 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
48/// Reads the first private key of `path`, whichever of the three encodings it is
49/// written in.
50///
51/// The PEM label is what says how the DER is encoded, and `rustls` needs to be
52/// told: `PRIVATE KEY` is PKCS#8, `EC PRIVATE KEY` is SEC1, `RSA PRIVATE KEY` is
53/// PKCS#1. An unknown label is reported *by name* — "expected a private key,
54/// found CERTIFICATE" is the whole diagnosis of a swapped `cert_path`/`key_path`.
55pub 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            // Not a key: keep looking, but remember what it was.
66            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
84/// Writes a private key PEM, owner-readable from the moment it exists.
85///
86/// `fs::write` would create the file with the process umask (commonly `0644`)
87/// and only tighten it afterwards, leaving a window in which any local user can
88/// read the key. Passing the mode to `open` closes that race. `create_new`
89/// additionally refuses to follow a pre-planted symlink.
90pub 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
106/// Writes `bytes` to `path` atomically, at `mode`.
107///
108/// Two properties `fs::write` does not have. **Atomic**: the content lands via a
109/// temporary file and a rename, so a crash part-way through leaves the previous
110/// version intact rather than a truncated one. That matters for the local CA's
111/// revocation ledger, which is the authoritative input to CRL generation — a
112/// half-written ledger read at the next startup is a set of revocations
113/// silently forgotten. **Owner-controlled**: the mode is passed to `open`
114/// rather than applied afterwards, the same reasoning as
115/// [`write_private_key`], so the file is never briefly world-readable.
116///
117/// The temporary file sits beside the target, since `rename` is only atomic
118/// within a filesystem.
119///
120/// **The scratch name belongs to one writer and one target.** It used to be
121/// `path.with_extension("tmp")`, shared two ways, and both hurt:
122///
123/// - Across *targets*, the local CA writes `ca.crl` and `ca.json` back to back
124///   and both mapped to `ca.tmp`, so a startup rebuilding the CRL while a
125///   revocation persisted the ledger could rename one file's bytes over the
126///   other's name. The observed symptom was a sidecar full of CRL PEM, which
127///   the next startup refuses to parse — every revocation this CA had ever
128///   recorded, unreadable, because two writes shared a scratch name. Appending
129///   the suffix rather than substituting the extension separates those.
130/// - Across *writers*, two processes truncating and filling one temp file
131///   interleave, and then each renames the mixture into place: atomic, and
132///   atomically wrong. The pid separates those.
133///
134/// The cost is that a crash leaves litter rather than a file the next run
135/// reuses, which is the right way round — a leftover temporary is inert, and
136/// the alternative was a shared mutable one.
137pub 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    // Unlink first, then `create_new` — i.e. `O_EXCL`, the same rule
146    // `write_private_key` above follows, and for two reasons rather than one.
147    //
148    // `mode` only applies to a file this call *creates*. Opening an existing
149    // path with `create(true)` leaves whatever permissions it already had, so a
150    // leftover scratch file — this pid's predecessor's, or one somebody planted
151    // — silently decides the mode of a file this function documents as
152    // owner-controlled. And an `open` of an existing path follows a symlink, so
153    // a planted one redirects the write somewhere else entirely; for the CRL
154    // that means an attacker who can write this directory but cannot read it
155    // chooses where relying parties' revocation data comes from.
156    //
157    // The unlink is what keeps the property the previous `create(true)` was
158    // there for: a leftover temporary must not wedge every subsequent write. It
159    // is removed rather than reused, and the `create_new` that follows then
160    // always creates, always applies `mode`, and never follows a link. Losing
161    // the race between the two — another writer creating the path in between —
162    // fails with `AlreadyExists`, which is the correct answer and not the silent
163    // follow it replaces.
164    //
165    // Deliberately not `custom_flags(O_NOFOLLOW)`: that constant differs per
166    // platform, so reaching it means a direct `libc` edge in a CA's dependency
167    // graph, for a case `create_new` already covers.
168    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    // Before the rename, or the rename can be durable while the content is not.
179    file.sync_all()?;
180    drop(file);
181
182    fs::rename(&temp, path)?;
183    Ok(())
184}
185
186/// Warns when an operator-supplied private key is group- or world-readable.
187///
188/// Loading it anyway is deliberate — refusing to start over file permissions
189/// would be a poor trade — but it should never pass unremarked. `event` names the
190/// subsystem, so a log line says *which* key is exposed.
191///
192/// This is the **one** place `event` is not a bare string literal, and the only
193/// exemption `tests/logging_convention.rs` grants: four subsystems share one
194/// warning rather than writing it out four times. Every caller passes
195/// `<subsystem>_key_permissive`.
196pub 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
215/// Reads a file, naming it in the error. `fs::read`'s own message does not.
216fn 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    /// A self-signed certificate PEM plus its key PEM.
226    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    /// A chain is read whole, in file order: `rustls` wants the leaf first, and
234    /// the CA that signed it after.
235    #[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        // The first block really is the first certificate, not the other one.
245        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    /// A key sitting in the same file is not mistaken for a certificate.
251    #[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    /// rcgen writes PKCS#8; the other two labels are what an operator's own
283    /// tooling produces.
284    #[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    /// The commonest misconfiguration: `key_path` pointed at the certificate.
313    /// The error has to say so.
314    #[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    /// The key is never readable by anyone else, not even briefly.
335    #[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        // `create_new`: an existing file is never silently overwritten.
347        assert!(write_private_key(&path, "x").is_err());
348    }
349
350    /// PEM body of `der`, wrapped at 64 characters.
351    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        // Overwriting an existing file must work — this is the case a
372        // `create_new` would have refused, and it is the normal one here.
373        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    /// The scratch path `write_atomic` actually uses, spelled the way it spells
406    /// it.
407    ///
408    /// Written out here because the tests below are worthless against any other
409    /// name: `path.with_extension("tmp")` is what this file used to write *and*
410    /// what these tests used to pre-create, so they exercised a path the
411    /// function never touched and passed for that reason.
412    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    /// A leftover temporary from a previous crash must not wedge every later
419    /// write. `create_new` alone would refuse it; the `remove_file` before it is
420    /// what keeps this working.
421    #[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    /// A stale temporary must not get to decide the mode of the file that
434    /// replaces it.
435    ///
436    /// `mode` applies only to a file the open *creates*, so with `create(true)`
437    /// this wrote the ledger into an existing `0o666` scratch file and renamed
438    /// it into place still `0o666` — owner-controlled in the doc comment and
439    /// world-writable on disk.
440    #[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    /// A symlink at the scratch path must not redirect the write.
460    ///
461    /// The reachable case is the CRL: an attacker who can write the server's
462    /// data directory but cannot read it would otherwise choose where the
463    /// revocation data relying parties fetch is written. `create_new` does not
464    /// follow a link, and the `remove_file` before it takes the link away rather
465    /// than failing on it.
466    #[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}