1use std::fs;
15use std::io;
16use std::path::{Path, PathBuf};
17
18#[cfg(unix)]
19const FILE_MODE: u32 = 0o600;
20#[cfg(unix)]
21const DIR_MODE: u32 = 0o700;
22
23const ACCOUNT_FILE: &str = "acme-account.json";
24const CA_FILE: &str = "ca.pem";
25const CA_KEY_FILE: &str = "ca-key.pem";
26const CERT_FILE: &str = "cert.pem";
27const KEY_FILE: &str = "cert-key.pem";
28const META_FILE: &str = "meta.json";
29
30#[derive(Debug, thiserror::Error)]
32pub enum StorageError {
33 #[error("could not prepare {path}: {source}")]
35 Io {
36 path: PathBuf,
38 #[source]
40 source: io::Error,
41 },
42 #[error("{zone:?} is not usable as a certificate directory name")]
44 InvalidZone {
45 zone: String,
47 },
48 #[error("{path} did not parse: {source}")]
50 Parse {
51 path: PathBuf,
53 #[source]
55 source: serde_json::Error,
56 },
57}
58
59#[derive(Debug, Clone)]
75pub struct CertStore {
76 dir: PathBuf,
77 account_dir: PathBuf,
78}
79
80#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub struct CertMeta {
86 pub directory_url: String,
93 #[serde(with = "time::serde::rfc3339")]
95 pub issued_at: time::OffsetDateTime,
96}
97
98pub struct StoredAccount {
107 pub credentials: instant_acme::AccountCredentials,
109 pub directory_url: Option<String>,
113}
114
115impl StoredAccount {
116 pub fn issued_by(&self, directory_url: &str) -> bool {
123 self.directory_url.as_deref() == Some(directory_url)
124 }
125}
126
127impl CertStore {
128 pub fn open(data_dir: &Path) -> Result<Self, StorageError> {
131 let dir = data_dir.join("tls");
132 create_dir(&dir)?;
133 Ok(Self {
134 account_dir: dir.clone(),
135 dir,
136 })
137 }
138
139 pub fn open_zone(data_dir: &Path, zone: &str) -> Result<Self, StorageError> {
153 let component = zone_directory_component(zone)?;
154 let account_dir = data_dir.join("tls");
155 create_dir(&account_dir)?;
156 let dir = account_dir.join("zones").join(component);
157 create_dir(&dir)?;
158 Ok(Self { dir, account_dir })
159 }
160
161 pub fn directory(&self) -> &Path {
163 &self.dir
164 }
165
166 pub fn account_credentials(&self) -> Result<Option<StoredAccount>, StorageError> {
169 let path = self.account_dir.join(ACCOUNT_FILE);
170 let Some(value): Option<serde_json::Value> = self.read_json_at(&path)? else {
171 return Ok(None);
172 };
173 let directory_url = value
178 .get("directory")
179 .and_then(serde_json::Value::as_str)
180 .map(str::to_owned);
181 let credentials =
182 serde_json::from_value(value).map_err(|source| StorageError::Parse { path, source })?;
183 Ok(Some(StoredAccount {
184 credentials,
185 directory_url,
186 }))
187 }
188
189 pub fn save_account_credentials(
191 &self,
192 credentials: &instant_acme::AccountCredentials,
193 ) -> Result<(), StorageError> {
194 write_json_at(&self.account_dir.join(ACCOUNT_FILE), credentials)
195 }
196
197 pub fn certificate(&self) -> Result<Option<(String, String, CertMeta)>, StorageError> {
199 let cert_path = self.dir.join(CERT_FILE);
200 if !cert_path.is_file() {
201 return Ok(None);
202 }
203 let cert_pem = self.read_string(CERT_FILE)?;
204 let key_pem = self.read_string(KEY_FILE)?;
205 let meta = self.read_json(META_FILE)?.ok_or_else(|| StorageError::Io {
206 path: self.dir.join(META_FILE),
207 source: io::Error::new(io::ErrorKind::NotFound, "cert.pem exists without meta.json"),
208 })?;
209 Ok(Some((cert_pem, key_pem, meta)))
210 }
211
212 pub fn save_certificate(
224 &self,
225 cert_pem: &str,
226 key_pem: &str,
227 meta: &CertMeta,
228 ) -> Result<(), StorageError> {
229 let key = Staged::new(&self.dir.join(KEY_FILE), key_pem.as_bytes())?;
230 let cert = Staged::new(&self.dir.join(CERT_FILE), cert_pem.as_bytes())?;
231 let meta = Staged::json(&self.dir.join(META_FILE), meta)?;
232 key.commit()?;
233 cert.commit()?;
234 meta.commit()
235 }
236
237 fn read_string(&self, name: &str) -> Result<String, StorageError> {
238 let path = self.dir.join(name);
239 fs::read_to_string(&path).map_err(|source| StorageError::Io { path, source })
240 }
241
242 fn read_json<T: serde::de::DeserializeOwned>(
243 &self,
244 name: &str,
245 ) -> Result<Option<T>, StorageError> {
246 self.read_json_at(&self.dir.join(name))
247 }
248
249 fn read_json_at<T: serde::de::DeserializeOwned>(
250 &self,
251 path: &Path,
252 ) -> Result<Option<T>, StorageError> {
253 let path = path.to_owned();
254 if !path.is_file() {
255 return Ok(None);
256 }
257 let text = fs::read_to_string(&path).map_err(|source| StorageError::Io {
258 path: path.clone(),
259 source,
260 })?;
261 serde_json::from_str(&text)
262 .map(Some)
263 .map_err(|source| StorageError::Parse { path, source })
264 }
265}
266
267#[derive(Debug, Clone)]
274pub struct CaStore {
275 dir: PathBuf,
276}
277
278impl CaStore {
279 pub fn open(dir: &Path) -> Result<Self, StorageError> {
282 create_dir(dir)?;
283 Ok(Self {
284 dir: dir.to_owned(),
285 })
286 }
287
288 pub fn certificate_path(&self) -> PathBuf {
292 self.dir.join(CA_FILE)
293 }
294
295 pub fn load(&self) -> Result<Option<(String, String)>, StorageError> {
300 let cert_path = self.certificate_path();
301 let key_path = self.dir.join(CA_KEY_FILE);
302 if !cert_path.is_file() || !key_path.is_file() {
303 return Ok(None);
304 }
305 Ok(Some((
306 read_string_at(&cert_path)?,
307 read_string_at(&key_path)?,
308 )))
309 }
310
311 pub fn save(&self, cert_pem: &str, key_pem: &str) -> Result<(), StorageError> {
317 let key = Staged::new(&self.dir.join(CA_KEY_FILE), key_pem.as_bytes())?;
318 let cert = Staged::new(&self.certificate_path(), cert_pem.as_bytes())?;
319 key.commit()?;
320 cert.commit()
321 }
322}
323
324fn read_string_at(path: &Path) -> Result<String, StorageError> {
326 fs::read_to_string(path).map_err(|source| StorageError::Io {
327 path: path.to_owned(),
328 source,
329 })
330}
331
332fn write_json_at<T: serde::Serialize>(path: &Path, value: &T) -> Result<(), StorageError> {
335 Staged::json(path, value)?.commit()
336}
337
338fn zone_directory_component(zone: &str) -> Result<String, StorageError> {
342 let component = zone.trim().to_ascii_lowercase();
343 let usable = !component.is_empty()
344 && component != "."
345 && component != ".."
346 && !component.starts_with('.')
347 && component
348 .chars()
349 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_');
350 if !usable {
351 return Err(StorageError::InvalidZone {
352 zone: zone.to_owned(),
353 });
354 }
355 Ok(component)
356}
357
358struct Staged {
373 scratch: PathBuf,
374 path: PathBuf,
375 committed: bool,
376}
377
378impl Staged {
379 fn new(path: &Path, contents: &[u8]) -> Result<Self, StorageError> {
381 let scratch = scratch_path(path);
382 match fs::remove_file(&scratch) {
387 Ok(()) => {}
388 Err(err) if err.kind() == io::ErrorKind::NotFound => {}
389 Err(source) => return Err(io_error(&scratch, source)),
390 }
391 let mut options = fs::OpenOptions::new();
392 options.write(true).create_new(true);
393 #[cfg(unix)]
394 {
395 use std::os::unix::fs::OpenOptionsExt;
396 options.mode(FILE_MODE);
397 }
398 let mut file = options
399 .open(&scratch)
400 .map_err(|source| io_error(&scratch, source))?;
401 let staged = Self {
402 scratch,
403 path: path.to_owned(),
404 committed: false,
405 };
406 io::Write::write_all(&mut file, contents)
407 .map_err(|source| io_error(&staged.scratch, source))?;
408 file.sync_all()
409 .map_err(|source| io_error(&staged.scratch, source))?;
410 Ok(staged)
411 }
412
413 fn json<T: serde::Serialize>(path: &Path, value: &T) -> Result<Self, StorageError> {
415 let text = serde_json::to_string_pretty(value).map_err(|source| StorageError::Parse {
416 path: path.to_owned(),
417 source,
418 })?;
419 Self::new(path, text.as_bytes())
420 }
421
422 fn commit(mut self) -> Result<(), StorageError> {
424 fs::rename(&self.scratch, &self.path).map_err(|source| io_error(&self.scratch, source))?;
425 self.committed = true;
426 sync_dir(self.path.parent().unwrap_or_else(|| Path::new(".")))
427 }
428}
429
430impl Drop for Staged {
431 fn drop(&mut self) {
432 if !self.committed {
433 let _ = fs::remove_file(&self.scratch);
434 }
435 }
436}
437
438fn scratch_path(path: &Path) -> PathBuf {
441 let mut name = path
442 .file_name()
443 .map(std::ffi::OsStr::to_os_string)
444 .unwrap_or_default();
445 name.push(".tmp");
446 path.with_file_name(name)
447}
448
449fn sync_dir(dir: &Path) -> Result<(), StorageError> {
451 match fs::File::open(dir) {
455 Ok(handle) => handle.sync_all().map_err(|source| io_error(dir, source)),
456 Err(_) => Ok(()),
457 }
458}
459
460fn io_error(path: &Path, source: io::Error) -> StorageError {
461 StorageError::Io {
462 path: path.to_owned(),
463 source,
464 }
465}
466
467fn create_dir(dir: &Path) -> Result<(), StorageError> {
471 if dir.is_dir() {
472 #[cfg(unix)]
473 {
474 use std::os::unix::fs::PermissionsExt;
475 fs::set_permissions(dir, fs::Permissions::from_mode(DIR_MODE)).map_err(|source| {
476 StorageError::Io {
477 path: dir.to_owned(),
478 source,
479 }
480 })?;
481 }
482 return Ok(());
483 }
484 let mut builder = fs::DirBuilder::new();
485 builder.recursive(true);
486 #[cfg(unix)]
487 {
488 use std::os::unix::fs::DirBuilderExt;
489 builder.mode(DIR_MODE);
490 }
491 builder.create(dir).map_err(|source| StorageError::Io {
492 path: dir.to_owned(),
493 source,
494 })
495}
496
497#[cfg(all(test, unix))]
498mod tests {
499 use super::*;
500 use std::os::unix::fs::PermissionsExt;
501
502 #[test]
503 fn creates_the_directory_0700() {
504 let tmp = tempdir();
505 let store = CertStore::open(tmp.path()).unwrap();
506 let mode = fs::metadata(tmp.path().join("tls"))
507 .unwrap()
508 .permissions()
509 .mode()
510 & 0o777;
511 assert_eq!(mode, DIR_MODE);
512 drop(store);
513 }
514
515 #[test]
516 fn tightens_a_directory_that_already_existed_wide_open() {
517 let tmp = tempdir();
518 let dir = tmp.path().join("tls");
519 fs::create_dir_all(&dir).unwrap();
520 fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
521
522 CertStore::open(tmp.path()).unwrap();
523
524 let mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
525 assert_eq!(mode, DIR_MODE);
526 }
527
528 #[test]
529 fn certificate_files_are_0600() {
530 let tmp = tempdir();
531 let store = CertStore::open(tmp.path()).unwrap();
532 let meta = CertMeta {
533 directory_url: "https://example.invalid/directory".into(),
534 issued_at: time::OffsetDateTime::now_utc(),
535 };
536 store.save_certificate("cert", "key", &meta).unwrap();
537
538 for name in [CERT_FILE, KEY_FILE, META_FILE] {
539 let mode = fs::metadata(tmp.path().join("tls").join(name))
540 .unwrap()
541 .permissions()
542 .mode()
543 & 0o777;
544 assert_eq!(mode, FILE_MODE, "{name} should be 0600");
545 }
546 }
547
548 #[test]
553 fn a_key_file_that_was_already_loose_is_tightened_by_the_next_write() {
554 let tmp = tempdir();
555 let store = CertStore::open(tmp.path()).unwrap();
556 let meta = CertMeta {
557 directory_url: "https://example.invalid/directory".into(),
558 issued_at: time::OffsetDateTime::now_utc(),
559 };
560 store.save_certificate("cert", "key", &meta).unwrap();
561
562 let key = tmp.path().join("tls").join(KEY_FILE);
563 fs::set_permissions(&key, fs::Permissions::from_mode(0o644)).unwrap();
564
565 store
566 .save_certificate("cert", "a renewed key", &meta)
567 .unwrap();
568
569 let mode = fs::metadata(&key).unwrap().permissions().mode() & 0o777;
570 assert_eq!(
571 mode, FILE_MODE,
572 "the private key is mode {mode:04o}; every local account can read it"
573 );
574 }
575
576 #[test]
577 fn round_trips_a_saved_certificate() {
578 let tmp = tempdir();
579 let store = CertStore::open(tmp.path()).unwrap();
580 let meta = CertMeta {
581 directory_url: "https://example.invalid/directory".into(),
582 issued_at: time::OffsetDateTime::now_utc(),
583 };
584 store
585 .save_certificate("the-cert", "the-key", &meta)
586 .unwrap();
587
588 let (cert, key, read_meta) = store.certificate().unwrap().unwrap();
589 assert_eq!(cert, "the-cert");
590 assert_eq!(key, "the-key");
591 assert_eq!(read_meta.directory_url, meta.directory_url);
592 }
593
594 #[test]
595 fn no_certificate_is_none_not_an_error() {
596 let tmp = tempdir();
597 let store = CertStore::open(tmp.path()).unwrap();
598 assert!(store.certificate().unwrap().is_none());
599 }
600
601 #[test]
605 fn a_scratch_file_left_by_a_dead_write_neither_hides_nor_replaces_the_pair() {
606 let tmp = tempdir();
607 let store = CertStore::open(tmp.path()).unwrap();
608 let meta = CertMeta {
609 directory_url: "https://example.invalid/directory".into(),
610 issued_at: time::OffsetDateTime::now_utc(),
611 };
612 store
613 .save_certificate("the-cert", "the-key", &meta)
614 .unwrap();
615
616 let key = tmp.path().join("tls").join(KEY_FILE);
617 let leftover = scratch_path(&key);
618 fs::write(&leftover, "half of a k").unwrap();
619 fs::set_permissions(&leftover, fs::Permissions::from_mode(0o644)).unwrap();
620
621 let (cert, key_pem, _) = store.certificate().unwrap().unwrap();
622 assert_eq!((cert.as_str(), key_pem.as_str()), ("the-cert", "the-key"));
623
624 store
625 .save_certificate("the-cert", "a renewed key", &meta)
626 .unwrap();
627
628 let (_, key_pem, _) = store.certificate().unwrap().unwrap();
629 assert_eq!(key_pem, "a renewed key");
630 let mode = fs::metadata(&key).unwrap().permissions().mode() & 0o777;
631 assert_eq!(
632 mode, FILE_MODE,
633 "the leftover's mode {mode:04o} travelled with the rename"
634 );
635 assert!(
636 !leftover.exists(),
637 "a committed save leaves no scratch file"
638 );
639 }
640
641 #[test]
647 fn a_save_that_fails_leaves_the_previous_pair_untouched() {
648 let tmp = tempdir();
649 let store = CertStore::open(tmp.path()).unwrap();
650 let meta = CertMeta {
651 directory_url: "https://example.invalid/directory".into(),
652 issued_at: time::OffsetDateTime::now_utc(),
653 };
654 store
655 .save_certificate("the-cert", "the-key", &meta)
656 .unwrap();
657
658 let dir = tmp.path().join("tls");
659 fs::create_dir(scratch_path(&dir.join(META_FILE))).unwrap();
660
661 store
662 .save_certificate("a renewed cert", "a renewed key", &meta)
663 .unwrap_err();
664
665 let (cert, key, _) = store.certificate().unwrap().unwrap();
666 assert_eq!((cert.as_str(), key.as_str()), ("the-cert", "the-key"));
667 for name in [CERT_FILE, KEY_FILE] {
668 assert!(
669 !scratch_path(&dir.join(name)).exists(),
670 "{name}'s scratch file outlived the save that failed"
671 );
672 }
673 }
674
675 fn tempdir() -> TempDir {
676 TempDir::new()
677 }
678
679 struct TempDir(PathBuf);
682
683 impl TempDir {
684 fn new() -> Self {
685 let mut dir = std::env::temp_dir();
686 let unique = format!(
687 "didbot-tls-test-{}-{}",
688 std::process::id(),
689 std::time::SystemTime::now()
690 .duration_since(std::time::UNIX_EPOCH)
691 .unwrap()
692 .as_nanos()
693 );
694 dir.push(unique);
695 fs::create_dir_all(&dir).unwrap();
696 Self(dir)
697 }
698
699 fn path(&self) -> &Path {
700 &self.0
701 }
702 }
703
704 impl Drop for TempDir {
705 fn drop(&mut self) {
706 let _ = fs::remove_dir_all(&self.0);
707 }
708 }
709}