acme-proxy-core 0.6.0

Configuration, ACME wire types and shared vocabulary for acme-proxy (internal crate, no semver promise)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
//! PEM material on disk: reading a certificate chain or a private key, and
//! writing a key that is never briefly world-readable.
//!
//! Two subsystems provision key material at startup — `signer::local_ca`
//! generates and reloads the CA, `tls` does the same for the HTTPS
//! listener's certificate — so the file hygiene lives here rather than in either
//! of them, the way `dns` holds the resolver that both `filter` and
//! `challenge` need.
//!
//! Reading goes through `x509-parser`'s label-agnostic PEM iterator, already in
//! the tree via `rcgen`. That is why `rustls-pemfile` is not a dependency: what
//! it would buy is the label match below.

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;

/// PEM label of an X.509 certificate.
const CERTIFICATE: &str = "CERTIFICATE";

/// Reads every `CERTIFICATE` block of `path`, in file order — which for a chain
/// means leaf first, as `rustls` expects.
///
/// Blocks with any other label are skipped, so a file holding both the chain and
/// its key is read correctly.
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)
}

/// Reads the first private key of `path`, whichever of the three encodings it is
/// written in.
///
/// The PEM label is what says how the DER is encoded, and `rustls` needs to be
/// told: `PRIVATE KEY` is PKCS#8, `EC PRIVATE KEY` is SEC1, `RSA PRIVATE KEY` is
/// PKCS#1. An unknown label is reported *by name* — "expected a private key,
/// found CERTIFICATE" is the whole diagnosis of a swapped `cert_path`/`key_path`.
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)),
            // Not a key: keep looking, but remember what it was.
            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(", ")
    );
}

/// Writes a private key PEM, owner-readable from the moment it exists.
///
/// `fs::write` would create the file with the process umask (commonly `0644`)
/// and only tighten it afterwards, leaving a window in which any local user can
/// read the key. Passing the mode to `open` closes that race. `create_new`
/// additionally refuses to follow a pre-planted symlink.
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(())
}

/// Writes `bytes` to `path` atomically, at `mode`.
///
/// Two properties `fs::write` does not have. **Atomic**: the content lands via a
/// temporary file and a rename, so a crash part-way through leaves the previous
/// version intact rather than a truncated one. That matters for the local CA's
/// revocation ledger, which is the authoritative input to CRL generation — a
/// half-written ledger read at the next startup is a set of revocations
/// silently forgotten. **Owner-controlled**: the mode is passed to `open`
/// rather than applied afterwards, the same reasoning as
/// [`write_private_key`], so the file is never briefly world-readable.
///
/// The temporary file sits beside the target, since `rename` is only atomic
/// within a filesystem.
///
/// **The scratch name belongs to one writer and one target.** It used to be
/// `path.with_extension("tmp")`, shared two ways, and both hurt:
///
/// - Across *targets*, the local CA writes `ca.crl` and `ca.json` back to back
///   and both mapped to `ca.tmp`, so a startup rebuilding the CRL while a
///   revocation persisted the ledger could rename one file's bytes over the
///   other's name. The observed symptom was a sidecar full of CRL PEM, which
///   the next startup refuses to parse — every revocation this CA had ever
///   recorded, unreadable, because two writes shared a scratch name. Appending
///   the suffix rather than substituting the extension separates those.
/// - Across *writers*, two processes truncating and filling one temp file
///   interleave, and then each renames the mixture into place: atomic, and
///   atomically wrong. The pid separates those.
///
/// The cost is that a crash leaves litter rather than a file the next run
/// reuses, which is the right way round — a leftover temporary is inert, and
/// the alternative was a shared mutable one.
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)
    };
    // Unlink first, then `create_new` — i.e. `O_EXCL`, the same rule
    // `write_private_key` above follows, and for two reasons rather than one.
    //
    // `mode` only applies to a file this call *creates*. Opening an existing
    // path with `create(true)` leaves whatever permissions it already had, so a
    // leftover scratch file — this pid's predecessor's, or one somebody planted
    // — silently decides the mode of a file this function documents as
    // owner-controlled. And an `open` of an existing path follows a symlink, so
    // a planted one redirects the write somewhere else entirely; for the CRL
    // that means an attacker who can write this directory but cannot read it
    // chooses where relying parties' revocation data comes from.
    //
    // The unlink is what keeps the property the previous `create(true)` was
    // there for: a leftover temporary must not wedge every subsequent write. It
    // is removed rather than reused, and the `create_new` that follows then
    // always creates, always applies `mode`, and never follows a link. Losing
    // the race between the two — another writer creating the path in between —
    // fails with `AlreadyExists`, which is the correct answer and not the silent
    // follow it replaces.
    //
    // Deliberately not `custom_flags(O_NOFOLLOW)`: that constant differs per
    // platform, so reaching it means a direct `libc` edge in a CA's dependency
    // graph, for a case `create_new` already covers.
    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)?;
    // Before the rename, or the rename can be durable while the content is not.
    file.sync_all()?;
    drop(file);

    fs::rename(&temp, path)?;
    Ok(())
}

/// Warns when an operator-supplied private key is group- or world-readable.
///
/// Loading it anyway is deliberate — refusing to start over file permissions
/// would be a poor trade — but it should never pass unremarked. `event` names the
/// subsystem, so a log line says *which* key is exposed.
///
/// This is the **one** place `event` is not a bare string literal, and the only
/// exemption `tests/logging_convention.rs` grants: four subsystems share one
/// warning rather than writing it out four times. Every caller passes
/// `<subsystem>_key_permissive`.
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);
}

/// Reads a file, naming it in the error. `fs::read`'s own message does not.
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;

    /// A self-signed certificate PEM plus its key PEM.
    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)
    }

    /// A chain is read whole, in file order: `rustls` wants the leaf first, and
    /// the CA that signed it after.
    #[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);
        // The first block really is the first certificate, not the other one.
        let expected = read_certificates(&dir.write("leaf.pem", &leaf)).unwrap();
        assert_eq!(chain[0], expected[0]);
        assert_ne!(chain[0], chain[1]);
    }

    /// A key sitting in the same file is not mistaken for a certificate.
    #[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}");
    }

    /// rcgen writes PKCS#8; the other two labels are what an operator's own
    /// tooling produces.
    #[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();
        }
    }

    /// The commonest misconfiguration: `key_path` pointed at the certificate.
    /// The error has to say so.
    #[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}");
    }

    /// The key is never readable by anyone else, not even briefly.
    #[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}");
        // `create_new`: an existing file is never silently overwritten.
        assert!(write_private_key(&path, "x").is_err());
    }

    /// PEM body of `der`, wrapped at 64 characters.
    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");

        // Overwriting an existing file must work — this is the case a
        // `create_new` would have refused, and it is the normal one here.
        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"
        );
    }

    /// The scratch path `write_atomic` actually uses, spelled the way it spells
    /// it.
    ///
    /// Written out here because the tests below are worthless against any other
    /// name: `path.with_extension("tmp")` is what this file used to write *and*
    /// what these tests used to pre-create, so they exercised a path the
    /// function never touched and passed for that reason.
    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)
    }

    /// A leftover temporary from a previous crash must not wedge every later
    /// write. `create_new` alone would refuse it; the `remove_file` before it is
    /// what keeps this working.
    #[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");
    }

    /// A stale temporary must not get to decide the mode of the file that
    /// replaces it.
    ///
    /// `mode` applies only to a file the open *creates*, so with `create(true)`
    /// this wrote the ledger into an existing `0o666` scratch file and renamed
    /// it into place still `0o666` — owner-controlled in the doc comment and
    /// world-writable on disk.
    #[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"
        );
    }

    /// A symlink at the scratch path must not redirect the write.
    ///
    /// The reachable case is the CRL: an attacker who can write the server's
    /// data directory but cannot read it would otherwise choose where the
    /// revocation data relying parties fetch is written. `create_new` does not
    /// follow a link, and the `remove_file` before it takes the link away rather
    /// than failing on it.
    #[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"
        );
    }
}