use std::future::Future;
use std::io;
use std::path::{Path, PathBuf};
use std::pin::Pin;
pub type StoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CertId(pub String);
impl CertId {
#[must_use]
pub fn from_domains(domains: &[String]) -> Self {
use sha2::{Digest as _, Sha256};
let mut sorted: Vec<&str> = domains.iter().map(String::as_str).collect();
sorted.sort_unstable();
sorted.dedup();
let mut hasher = Sha256::new();
for domain in sorted {
hasher.update(domain.as_bytes());
hasher.update(b"\0");
}
let digest = hasher.finalize();
let mut out = String::with_capacity(32);
for byte in &digest[..16] {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
Self(out)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredCert {
pub chain_pem: String,
pub key_pem: String,
}
pub trait AcmeStore: Send + Sync {
fn load_account(&self) -> StoreFuture<'_, io::Result<Option<Vec<u8>>>>;
fn save_account<'a>(&'a self, data: &'a [u8]) -> StoreFuture<'a, io::Result<()>>;
fn load_cert<'a>(&'a self, id: &'a CertId) -> StoreFuture<'a, io::Result<Option<StoredCert>>>;
fn save_cert<'a>(
&'a self,
id: &'a CertId,
cert: &'a StoredCert,
) -> StoreFuture<'a, io::Result<()>>;
}
#[derive(Debug, Clone)]
pub struct FsAcmeStore {
dir: PathBuf,
directory_label: String,
}
impl FsAcmeStore {
#[must_use]
pub fn new(dir: impl Into<PathBuf>, directory_label: impl Into<String>) -> Self {
Self {
dir: dir.into(),
directory_label: directory_label.into(),
}
}
fn cert_dir(&self) -> PathBuf {
self.dir.join(&self.directory_label)
}
fn account_path(&self) -> PathBuf {
self.cert_dir().join("account.json")
}
fn chain_path(&self, id: &CertId) -> PathBuf {
self.cert_dir().join(format!("{}.chain.pem", id.as_str()))
}
fn key_path(&self, id: &CertId) -> PathBuf {
self.cert_dir().join(format!("{}.key.pem", id.as_str()))
}
}
impl AcmeStore for FsAcmeStore {
fn load_account(&self) -> StoreFuture<'_, io::Result<Option<Vec<u8>>>> {
let path = self.account_path();
Box::pin(async move { read_optional(&path).await })
}
fn save_account<'a>(&'a self, data: &'a [u8]) -> StoreFuture<'a, io::Result<()>> {
let dir = self.cert_dir();
let path = self.account_path();
let data = data.to_vec();
Box::pin(async move {
ensure_dir(&dir).await?;
write_owner_only(&path, &data).await
})
}
fn load_cert<'a>(&'a self, id: &'a CertId) -> StoreFuture<'a, io::Result<Option<StoredCert>>> {
let chain_path = self.chain_path(id);
let key_path = self.key_path(id);
Box::pin(async move {
let (Some(chain), Some(key)) = (
read_optional(&chain_path).await?,
read_optional(&key_path).await?,
) else {
return Ok(None);
};
Ok(Some(StoredCert {
chain_pem: String::from_utf8(chain)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
key_pem: String::from_utf8(key)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
}))
})
}
fn save_cert<'a>(
&'a self,
id: &'a CertId,
cert: &'a StoredCert,
) -> StoreFuture<'a, io::Result<()>> {
let dir = self.cert_dir();
let chain_path = self.chain_path(id);
let key_path = self.key_path(id);
let chain = cert.chain_pem.clone().into_bytes();
let key = cert.key_pem.clone().into_bytes();
Box::pin(async move {
ensure_dir(&dir).await?;
let chain_tmp = stage_owner_only(&chain_path, &chain).await?;
let key_tmp = match stage_owner_only(&key_path, &key).await {
Ok(tmp) => tmp,
Err(e) => {
let _ = tokio::fs::remove_file(&chain_tmp).await;
return Err(e);
}
};
publish_staged(&chain_tmp, &chain_path).await?;
publish_staged(&key_tmp, &key_path).await
})
}
}
async fn read_optional(path: &Path) -> io::Result<Option<Vec<u8>>> {
match tokio::fs::read(path).await {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
async fn ensure_dir(dir: &Path) -> io::Result<()> {
tokio::fs::create_dir_all(dir).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let _ = tokio::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).await;
}
Ok(())
}
async fn write_owner_only(path: &Path, data: &[u8]) -> io::Result<()> {
let tmp = stage_owner_only(path, data).await?;
publish_staged(&tmp, path).await
}
async fn stage_owner_only(path: &Path, data: &[u8]) -> io::Result<PathBuf> {
use tokio::io::AsyncWriteExt as _;
let tmp = tmp_path(path);
let mut options = tokio::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
options.mode(0o600);
}
let mut file = options.open(&tmp).await?;
file.write_all(data).await?;
file.flush().await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)).await?;
}
Ok(tmp)
}
async fn publish_staged(tmp: &Path, path: &Path) -> io::Result<()> {
if let Err(e) = tokio::fs::rename(tmp, path).await {
let _ = tokio::fs::remove_file(tmp).await;
return Err(e);
}
Ok(())
}
fn tmp_path(path: &Path) -> PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(".tmp");
PathBuf::from(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cert_id_is_order_independent_and_deduped() {
let a = CertId::from_domains(&["b.example.com".into(), "a.example.com".into()]);
let b = CertId::from_domains(&["a.example.com".into(), "b.example.com".into()]);
let c = CertId::from_domains(&[
"a.example.com".into(),
"b.example.com".into(),
"a.example.com".into(),
]);
assert_eq!(a, b);
assert_eq!(a, c);
assert_eq!(a.as_str().len(), 32);
}
#[test]
fn cert_id_distinct_domain_sets_differ() {
let a = CertId::from_domains(&["a.example.com".into()]);
let b = CertId::from_domains(&["b.example.com".into()]);
assert_ne!(a, b);
}
#[tokio::test]
async fn account_save_load_round_trip() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path().join("acme"), "staging");
assert!(store.load_account().await.unwrap().is_none());
store.save_account(b"creds-json").await.unwrap();
assert_eq!(
store.load_account().await.unwrap().as_deref(),
Some(&b"creds-json"[..])
);
}
#[tokio::test]
async fn account_is_keyed_per_directory() {
let dir = tempfile::tempdir().unwrap();
let staging = FsAcmeStore::new(dir.path(), "staging");
let production = FsAcmeStore::new(dir.path(), "production");
staging.save_account(b"staging-creds").await.unwrap();
assert!(production.load_account().await.unwrap().is_none());
assert_eq!(
staging.load_account().await.unwrap().as_deref(),
Some(&b"staging-creds"[..])
);
}
#[tokio::test]
async fn cert_save_load_round_trip() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path(), "staging");
let id = CertId::from_domains(&["app.example.com".into()]);
assert!(store.load_cert(&id).await.unwrap().is_none());
let cert = StoredCert {
chain_pem: "CHAIN".into(),
key_pem: "KEY".into(),
};
store.save_cert(&id, &cert).await.unwrap();
assert_eq!(store.load_cert(&id).await.unwrap(), Some(cert));
}
#[tokio::test]
async fn partial_cert_reads_as_absent() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path(), "staging");
let id = CertId::from_domains(&["app.example.com".into()]);
let chain_path = store.chain_path(&id);
tokio::fs::create_dir_all(chain_path.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&chain_path, b"CHAIN").await.unwrap();
assert!(store.load_cert(&id).await.unwrap().is_none());
}
#[tokio::test]
async fn cert_is_namespaced_per_directory() {
let dir = tempfile::tempdir().unwrap();
let staging = FsAcmeStore::new(dir.path(), "staging");
let production = FsAcmeStore::new(dir.path(), "production");
let id = CertId::from_domains(&["app.example.com".into()]);
let staging_cert = StoredCert {
chain_pem: "STAGING-CHAIN".into(),
key_pem: "STAGING-KEY".into(),
};
staging.save_cert(&id, &staging_cert).await.unwrap();
assert!(production.load_cert(&id).await.unwrap().is_none());
assert_eq!(staging.load_cert(&id).await.unwrap(), Some(staging_cert));
}
#[tokio::test]
async fn save_cert_publishes_both_files_without_leftover_temps() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path(), "staging");
let id = CertId::from_domains(&["app.example.com".into()]);
store
.save_cert(
&id,
&StoredCert {
chain_pem: "CHAIN".into(),
key_pem: "KEY".into(),
},
)
.await
.unwrap();
assert!(store.chain_path(&id).exists(), "chain must be published");
assert!(store.key_path(&id).exists(), "key must be published");
let mut entries = tokio::fs::read_dir(store.cert_dir()).await.unwrap();
while let Some(entry) = entries.next_entry().await.unwrap() {
let name = entry.file_name();
assert!(
!name.to_string_lossy().ends_with(".tmp"),
"no staged temp file should linger, found {name:?}"
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn written_files_are_owner_only_0600() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path().join("acme"), "staging");
store.save_account(b"secret").await.unwrap();
let id = CertId::from_domains(&["app.example.com".into()]);
store
.save_cert(
&id,
&StoredCert {
chain_pem: "CHAIN".into(),
key_pem: "KEY".into(),
},
)
.await
.unwrap();
for path in [
store.account_path(),
store.chain_path(&id),
store.key_path(&id),
] {
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode,
0o600,
"{} should be 0600, was {mode:o}",
path.display()
);
}
let subdir_mode = std::fs::metadata(store.cert_dir())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(subdir_mode, 0o700, "cert subdir should be 0700");
}
}