Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/src/acme/cache.rs

17.0 KiB, 58 runs

created by r1870400018:9515, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Disk-backed cache for ACME client state.
2//!
3//! The ACME client needs three things to persist across restarts so it can
4//! resume instead of re-registering and re-issuing from scratch every time:
5//!
6//! 1. The account private key (PKCS#8), generated once and reused for the
7//! lifetime of the ACME account.
8//! 2. The currently-issued certificate chain in PEM form.
9//! 3. The matching private key in PKCS#8 DER form.
10//!
11//! This module owns the file layout under a single cache directory:
12//!
13//! ```text
14//! <cache_dir>/
15//! account_key.pkcs8 <- raw PKCS#8 DER bytes for the ACME account
16//! cert.pem <- issued TLS cert chain in PEM
17//! cert_key.pkcs8 <- matching TLS private key in PKCS#8 DER
18//! ```
19//!
20//! All writes go through an atomic write-then-rename helper so a crashed
21//! or killed process cannot leave a partial file behind that the next
22//! start-up would read as truncated garbage. The two private keys go
23//! through `fe2o3_core`'s secret variant on top of that, so they land at
24//! mode 0600 whatever the umask; `cert.pem` is public and keeps the plain
25//! atomic write.
26//!
27//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
28//! Anthropic Claude
29
30use crate::acme::jose::JwsSigner;
31
32use oxedyne_fe2o3_core::{
33 prelude::*,
34 file as core_file,
35};
36
37use std::{
38 fs,
39 io::Write,
40 path::{
41 Path,
42 PathBuf,
43 },
44};
45
46
47// ┌───────────────────────────────────────────────────────────────────────────┐
48// │ CACHE │
49// └───────────────────────────────────────────────────────────────────────────┘
50
51/// An ACME client's account key and last-issued certificate, under one root
52/// directory.
53#[derive(Clone, Debug)]
54pub struct AcmeDiskCache {
55 root: PathBuf,
56}
57
58impl AcmeDiskCache {
59
60 /// Creates the root directory, at mode 0700, if it is not already there.
61 pub fn new<P: AsRef<Path>>(root: P) -> Outcome<Self> {
62 let root = root.as_ref().to_path_buf();
63 if let Err(e) = core_file::create_secret_dir(&root) {
64 return Err(err!(e,
65 "Failed to create ACME cache directory {:?}.", root;
66 File, IO, Init));
67 }
68 Ok(Self { root })
69 }
70
71 pub fn root(&self) -> &Path {
72 &self.root
73 }
74
75 /// Named whether or not the file exists, so a caller may probe its mtime or
76 /// delete it.
77 pub fn certificate_path(&self) -> PathBuf {
78 self.root.join(CERT_PEM_FILE)
79 }
80
81 /// Named whether or not the file exists.
82 pub fn account_key_path(&self) -> PathBuf {
83 self.root.join(ACCOUNT_KEY_FILE)
84 }
85
86 /// `Ok(None)` where no key has been stored yet, which is the first run.
87 pub fn load_account_key(&self) -> Outcome<Option<JwsSigner>> {
88 let path = self.root.join(ACCOUNT_KEY_FILE);
89 if !path.exists() {
90 return Ok(None);
91 }
92 // Tighten a key that predates `save_secret`, or that arrived at a
93 // wider mode some other way, before it is ever read.
94 res!(core_file::restrict_secret(&path));
95 let bytes = match fs::read(&path) {
96 Ok(b) => b,
97 Err(e) => return Err(err!(e,
98 "Failed to read cached account key at {:?}.", path;
99 File, IO, Read)),
100 };
101 Ok(Some(res!(JwsSigner::from_pkcs8(&bytes))))
102 }
103
104 /// The write is atomic, replaces any existing key, and leaves the file
105 /// at mode 0600 whatever the umask: this is the account's private key.
106 pub fn store_account_key(&self, signer: &JwsSigner) -> Outcome<()> {
107 let path = self.root.join(ACCOUNT_KEY_FILE);
108 res!(core_file::save_secret(&path, signer.pkcs8_bytes()));
109 Ok(())
110 }
111
112 /// The pair is `(cert_pem, key_pkcs8_der)`, verbatim as stored; parsing the
113 /// chain and handing the key to `rustls` is the caller's job. `Ok(None)` where
114 /// either file is missing, so a half-populated cache reads as no cache.
115 pub fn load_certificate(&self) -> Outcome<Option<(Vec<u8>, Vec<u8>)>> {
116 let cert_path = self.root.join(CERT_PEM_FILE);
117 let key_path = self.root.join(CERT_KEY_FILE);
118 if !cert_path.exists() || !key_path.exists() {
119 return Ok(None);
120 }
121 let cert = match fs::read(&cert_path) {
122 Ok(b) => b,
123 Err(e) => return Err(err!(e,
124 "Failed to read cached certificate at {:?}.", cert_path;
125 File, IO, Read)),
126 };
127 // Tighten the private key before reading it; the certificate above
128 // stays untouched, since it is public.
129 res!(core_file::restrict_secret(&key_path));
130 let key = match fs::read(&key_path) {
131 Ok(b) => b,
132 Err(e) => return Err(err!(e,
133 "Failed to read cached certificate key at {:?}.", key_path;
134 File, IO, Read)),
135 };
136 Ok(Some((cert, key)))
137 }
138
139 /// Each file is written atomically, but the pair is not: an interruption
140 /// between the two leaves a new certificate beside the old key. The
141 /// certificate is public, so it keeps the ordinary atomic write; the key
142 /// goes through the secret path and lands at mode 0600.
143 pub fn store_certificate(
144 &self,
145 cert_pem: &[u8],
146 key_pkcs8: &[u8],
147 )
148 -> Outcome<()>
149 {
150 res!(write_atomic(&self.root.join(CERT_PEM_FILE), cert_pem));
151 res!(core_file::save_secret(&self.root.join(CERT_KEY_FILE), key_pkcs8));
152 Ok(())
153 }
154}
155
156
157// ┌───────────────────────────────────────────────────────────────────────────┐
158// │ CONSTANTS │
159// └───────────────────────────────────────────────────────────────────────────┘
160
161const ACCOUNT_KEY_FILE: &str = "account_key.pkcs8";
162const CERT_PEM_FILE: &str = "cert.pem";
163const CERT_KEY_FILE: &str = "cert_key.pkcs8";
164
165
166// ┌───────────────────────────────────────────────────────────────────────────┐
167// │ ATOMIC WRITE │
168// └───────────────────────────────────────────────────────────────────────────┘
169
170/// Writes to `<path>.tmp`, fsyncs, then renames, so an interrupted writer never
171/// leaves a half-written file under the real name.
172fn write_atomic(path: &Path, data: &[u8]) -> Outcome<()> {
173 let file_name = match path.file_name() {
174 Some(n) => n.to_os_string(),
175 None => return Err(err!(
176 "ACME cache path {:?} has no file-name component.", path;
177 Invalid, Input, Path)),
178 };
179 let mut tmp = path.to_path_buf();
180 tmp.set_file_name(fmt!("{}.tmp", file_name.to_string_lossy()));
181
182 {
183 let mut f = match fs::File::create(&tmp) {
184 Ok(f) => f,
185 Err(e) => return Err(err!(e,
186 "Failed to create temporary file {:?}.", tmp;
187 File, IO, Create)),
188 };
189 if let Err(e) = f.write_all(data) {
190 return Err(err!(e,
191 "Failed to write to temporary file {:?}.", tmp;
192 File, IO, Write));
193 }
194 if let Err(e) = f.sync_all() {
195 return Err(err!(e,
196 "Failed to fsync temporary file {:?}.", tmp;
197 File, IO, Write));
198 }
199 }
200
201 if let Err(e) = fs::rename(&tmp, path) {
202 return Err(err!(e,
203 "Failed to rename {:?} -> {:?}.", tmp, path;
204 File, IO, Write));
205 }
206 Ok(())
207}
208
209
210// ┌───────────────────────────────────────────────────────────────────────────┐
211// │ TESTS │
212// └───────────────────────────────────────────────────────────────────────────┘
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 use std::sync::atomic::{
219 AtomicU64,
220 Ordering,
221 };
222
223 // Combined with the PID this gives each test a path that cannot collide,
224 // even when the suite runs across threads.
225 static COUNTER: AtomicU64 = AtomicU64::new(0);
226
227 /// A cache directory under `/tmp` that is unique per test run and per
228 /// test, with a best-effort Drop that cleans it up.
229 struct ScratchDir {
230 path: PathBuf,
231 }
232
233 impl ScratchDir {
234 fn new(label: &str) -> Self {
235 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
236 let path = std::env::temp_dir().join(fmt!(
237 "fe2o3_acme_cache_test_{}_{}_{}",
238 std::process::id(),
239 n,
240 label,
241 ));
242 let _ = fs::remove_dir_all(&path);
243 Self { path }
244 }
245 }
246
247 impl Drop for ScratchDir {
248 fn drop(&mut self) {
249 let _ = fs::remove_dir_all(&self.path);
250 }
251 }
252
253 /// New cache in an empty directory: account key load returns None,
254 /// certificate load returns None.
255 #[test]
256 fn test_empty_cache_reports_none() -> Outcome<()> {
257 let scratch = ScratchDir::new("empty");
258 let cache = res!(AcmeDiskCache::new(&scratch.path));
259 match res!(cache.load_account_key()) {
260 None => (),
261 Some(_) => return Err(err!(
262 "Empty cache returned Some(account_key).";
263 Test, Mismatch)),
264 }
265 match res!(cache.load_certificate()) {
266 None => (),
267 Some(_) => return Err(err!(
268 "Empty cache returned Some(certificate).";
269 Test, Mismatch)),
270 }
271 Ok(())
272 }
273
274 /// Store an account key and load it back: the reloaded signer must
275 /// expose the same PKCS#8 bytes, which in turn proves it holds the
276 /// same key material.
277 #[test]
278 fn test_account_key_round_trip() -> Outcome<()> {
279 let scratch = ScratchDir::new("account_key");
280 let cache = res!(AcmeDiskCache::new(&scratch.path));
281 let signer = res!(JwsSigner::new_es256());
282 let original_pkcs8 = signer.pkcs8_bytes().to_vec();
283
284 res!(cache.store_account_key(&signer));
285 let loaded = match res!(cache.load_account_key()) {
286 Some(s) => s,
287 None => return Err(err!(
288 "load_account_key returned None immediately after \
289 store_account_key.";
290 Test, Missing)),
291 };
292
293 if loaded.pkcs8_bytes() != original_pkcs8.as_slice() {
294 return Err(err!(
295 "Reloaded account key has different PKCS#8 bytes (orig {} \
296 bytes, reload {} bytes).",
297 original_pkcs8.len(), loaded.pkcs8_bytes().len();
298 Test, Mismatch));
299 }
300 Ok(())
301 }
302
303 /// Store a certificate and load it back: both blobs must round-trip
304 /// byte-for-byte.
305 #[test]
306 fn test_certificate_round_trip() -> Outcome<()> {
307 let scratch = ScratchDir::new("cert");
308 let cache = res!(AcmeDiskCache::new(&scratch.path));
309
310 let cert_pem = b"-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n";
311 let key_pkcs8 = &[0x30u8, 0x01, 0x02, 0x03, 0x04];
312
313 res!(cache.store_certificate(cert_pem, key_pkcs8));
314 let (loaded_cert, loaded_key) = match res!(cache.load_certificate()) {
315 Some(pair) => pair,
316 None => return Err(err!(
317 "load_certificate returned None immediately after \
318 store_certificate.";
319 Test, Missing)),
320 };
321
322 if loaded_cert != cert_pem {
323 return Err(err!(
324 "Reloaded cert PEM does not match stored bytes.";
325 Test, Mismatch));
326 }
327 if loaded_key != key_pkcs8 {
328 return Err(err!(
329 "Reloaded cert key does not match stored bytes.";
330 Test, Mismatch));
331 }
332 Ok(())
333 }
334
335 /// With only the cert file present and the key file missing, the
336 /// load must return None (both-or-nothing semantics).
337 #[test]
338 fn test_partial_cert_state_reports_none() -> Outcome<()> {
339 let scratch = ScratchDir::new("partial_cert");
340 let cache = res!(AcmeDiskCache::new(&scratch.path));
341
342 let cert_path = scratch.path.join(CERT_PEM_FILE);
343 if let Err(e) = fs::write(&cert_path, b"not a real cert") {
344 return Err(err!(e,
345 "Failed to pre-seed the cert file for the partial-state test.";
346 Test, File, IO, Write));
347 }
348
349 match res!(cache.load_certificate()) {
350 None => Ok(()),
351 Some(_) => Err(err!(
352 "load_certificate returned Some even though the key file \
353 is missing.";
354 Test, Mismatch)),
355 }
356 }
357
358 /// Store twice with different contents to confirm the atomic rename
359 /// actually replaces the previous file rather than appending.
360 #[test]
361 fn test_atomic_overwrite() -> Outcome<()> {
362 let scratch = ScratchDir::new("overwrite");
363 let cache = res!(AcmeDiskCache::new(&scratch.path));
364
365 res!(cache.store_certificate(b"v1 cert", b"v1 key"));
366 res!(cache.store_certificate(b"v2 cert much longer", b"v2 key"));
367
368 let (cert, key) = match res!(cache.load_certificate()) {
369 Some(p) => p,
370 None => return Err(err!(
371 "load_certificate returned None after overwrite.";
372 Test, Missing)),
373 };
374 if cert != b"v2 cert much longer" {
375 return Err(err!(
376 "cert did not overwrite: got {:?}.",
377 String::from_utf8_lossy(&cert);
378 Test, Mismatch));
379 }
380 if key != b"v2 key" {
381 return Err(err!(
382 "key did not overwrite: got {:?}.",
383 String::from_utf8_lossy(&key);
384 Test, Mismatch));
385 }
386 Ok(())
387 }
388
389 /// The two private keys must be readable only by their owner; the
390 /// certificate is public and must not be locked down by the same change.
391 #[test]
392 #[cfg(unix)]
393 fn test_key_files_are_saved_0600_and_cert_pem_is_not() -> Outcome<()> {
394 use std::os::unix::fs::PermissionsExt;
395
396 let scratch = ScratchDir::new("mode");
397 let cache = res!(AcmeDiskCache::new(&scratch.path));
398 let signer = res!(JwsSigner::new_es256());
399
400 res!(cache.store_account_key(&signer));
401 res!(cache.store_certificate(b"cert bytes", b"key bytes"));
402
403 let account_mode = res!(fs::metadata(cache.account_key_path()))
404 .permissions().mode() & 0o777;
405 if account_mode != 0o600 {
406 return Err(err!(
407 "account_key.pkcs8 saved at mode {:o}, not 0600.", account_mode;
408 Test, Mismatch));
409 }
410
411 let key_path = scratch.path.join(CERT_KEY_FILE);
412 let key_mode = res!(fs::metadata(&key_path)).permissions().mode() & 0o777;
413 if key_mode != 0o600 {
414 return Err(err!(
415 "cert_key.pkcs8 saved at mode {:o}, not 0600.", key_mode;
416 Test, Mismatch));
417 }
418
419 // cert.pem must end up at whatever mode an ordinary, non-secret write
420 // gets in this environment -- not specifically 0600 -- since a strict
421 // umask (0077, as under this fleet's `UMask=` hardening) puts a plain
422 // `fs::write` at 0600 too. Comparing against a control file written
423 // the same ordinary way, rather than asserting `!= 0o600` outright,
424 // is what actually distinguishes "not specially restricted" from
425 // "happens to match the secret mode under this umask".
426 let control_path = scratch.path.join("control.pem");
427 res!(fs::write(&control_path, b"not a secret"));
428 let control_mode = res!(fs::metadata(&control_path)).permissions().mode() & 0o777;
429
430 let cert_mode = res!(fs::metadata(cache.certificate_path()))
431 .permissions().mode() & 0o777;
432 if cert_mode != control_mode {
433 return Err(err!(
434 "cert.pem saved at mode {:o}, but an ordinary write here lands at {:o}: \
435 cert.pem is being restricted like a secret.", cert_mode, control_mode;
436 Test, Mismatch));
437 }
438 Ok(())
439 }
440}