use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock, RwLock};
use std::time::{Duration, SystemTime};
use instant_acme::{
Account, AccountCredentials, ChallengeType, Identifier, NewAccount, NewOrder, OrderStatus,
};
use rcgen::{CertificateParams, KeyPair, PKCS_ECDSA_P256_SHA256};
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::server::{ClientHello, ResolvesServerCert};
use rustls::sign::CertifiedKey;
use tracing::{error, info, warn};
use webpki::EndEntityCert;
fn write_private_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
use std::io::Write;
#[cfg(unix)]
let mut file = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?
};
#[cfg(not(unix))]
let mut file = fs::File::create(path)?;
file.write_all(contents)
}
static ACTIVE_CHALLENGES: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
#[inline]
fn challenges() -> &'static RwLock<HashMap<String, String>> {
ACTIVE_CHALLENGES.get_or_init(|| RwLock::new(HashMap::new()))
}
pub fn register_challenge(token: String, key_authorization: String) {
if let Ok(mut map) = challenges().write() {
let _ = map.insert(token, key_authorization);
} else {
error!("[acme] Failed to acquire write lock for challenge registration");
}
}
pub fn unregister_challenge(token: &str) {
if let Ok(mut map) = challenges().write() {
let _ = map.remove(token);
}
}
#[inline]
#[must_use]
pub fn get_challenge(token: &str) -> Option<String> {
challenges()
.read()
.ok()
.and_then(|map| map.get(token).cloned())
}
#[derive(Debug)]
pub struct AcmeResolver {
current_key: RwLock<Option<Arc<CertifiedKey>>>,
}
impl AcmeResolver {
#[must_use]
pub const fn new() -> Self {
Self {
current_key: RwLock::new(None),
}
}
pub fn update_cert(&self, certified_key: CertifiedKey) {
match self.current_key.write() {
Ok(mut lock) => {
*lock = Some(Arc::new(certified_key));
info!("[acme] Certificate hot-swapped into TLS resolver");
}
Err(e) => error!("[acme] Failed to update certificate in resolver: {e}"),
}
}
pub fn has_certificate(&self) -> bool {
self.current_key
.read()
.ok()
.and_then(|g| g.as_ref().map(|_| ()))
.is_some()
}
}
impl Default for AcmeResolver {
fn default() -> Self {
Self::new()
}
}
impl ResolvesServerCert for AcmeResolver {
fn resolve(&self, _client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
self.current_key.read().ok()?.clone()
}
}
#[derive(Debug)]
pub enum AcmeError {
Io(std::io::Error),
Acme(instant_acme::Error),
CertGen(rcgen::Error),
Json(serde_json::Error),
OrderInvalid,
MissingPrivateKey,
CertParse(String),
TlsKeyLoad(String),
}
impl std::fmt::Display for AcmeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::Acme(e) => write!(f, "ACME protocol error: {e}"),
Self::CertGen(e) => write!(f, "Certificate generation error: {e}"),
Self::Json(e) => write!(f, "JSON error: {e}"),
Self::OrderInvalid => write!(f, "ACME order was rejected by CA"),
Self::MissingPrivateKey => write!(f, "No private key found in PEM data"),
Self::CertParse(s) => write!(f, "Certificate parse error: {s}"),
Self::TlsKeyLoad(s) => write!(f, "TLS signing key load failed: {s}"),
}
}
}
impl std::error::Error for AcmeError {}
impl From<std::io::Error> for AcmeError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<instant_acme::Error> for AcmeError {
fn from(e: instant_acme::Error) -> Self {
Self::Acme(e)
}
}
impl From<rcgen::Error> for AcmeError {
fn from(e: rcgen::Error) -> Self {
Self::CertGen(e)
}
}
impl From<serde_json::Error> for AcmeError {
fn from(e: serde_json::Error) -> Self {
Self::Json(e)
}
}
#[derive(Debug)]
pub struct AcmeManager {
domains: Vec<String>,
email: String,
cache_dir: PathBuf,
is_staging: bool,
resolver: Arc<AcmeResolver>,
provisioning: tokio::sync::Mutex<()>,
}
const RENEW_THRESHOLD: Duration = Duration::from_hours(30 * 24); const CHECK_INTERVAL: Duration = Duration::from_hours(24);
const BACKOFF_INITIAL: Duration = Duration::from_mins(5);
const BACKOFF_MAX: Duration = Duration::from_hours(6);
impl AcmeManager {
pub fn new(
cache_dir: impl Into<PathBuf>,
domains: Vec<String>,
email: String,
is_staging: bool,
) -> Arc<Self> {
let cache_dir = cache_dir.into();
if let Err(e) = fs::create_dir_all(&cache_dir) {
error!(
"[acme] Failed to create cache directory {:?}: {e}",
cache_dir
);
}
Arc::new(Self {
domains,
email,
cache_dir,
is_staging,
resolver: Arc::new(AcmeResolver::new()),
provisioning: tokio::sync::Mutex::new(()),
})
}
pub fn resolver(&self) -> Arc<AcmeResolver> {
self.resolver.clone()
}
pub fn start(self: Arc<Self>) {
drop(tokio::spawn(async move {
self.run_loop().await;
}));
}
async fn run_loop(&self) {
let mut backoff = BACKOFF_INITIAL;
loop {
let needs_provisioning = match self.load_and_activate_cached_cert() {
Ok(true) => {
backoff = BACKOFF_INITIAL;
false
}
Ok(false) => {
info!("[acme] No valid cached certificate — provisioning new one");
true
}
Err(e) => {
warn!("[acme] Error loading cached certificate: {e}");
true
}
};
if needs_provisioning {
match self.provision_cert().await {
Ok((certs, key)) => {
info!("[acme] Successfully provisioned new certificate from Let's Encrypt");
match Self::build_certified_key(certs, key) {
Ok(certified_key) => {
self.resolver.update_cert(certified_key);
backoff = BACKOFF_INITIAL; }
Err(e) => {
error!(
"[acme] Failed to build TLS signing key: {e}. Retrying in {:?}",
backoff
);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(BACKOFF_MAX);
continue;
}
}
}
Err(e) => {
error!(
"[acme] Certificate provisioning failed: {e}. Retrying in {:?}",
backoff
);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(BACKOFF_MAX);
continue; }
}
}
tokio::time::sleep(CHECK_INTERVAL).await;
}
}
fn load_and_activate_cached_cert(&self) -> Result<bool, AcmeError> {
let Ok((certs, key)) = self.load_cached_certs_and_key() else {
return Ok(false); };
let Some(expiry) = Self::check_cert_expiry(&certs) else {
return Ok(false);
};
if !Self::cert_matches_domains(&certs, &self.domains) {
warn!(
"[acme] Cached certificate in {:?} does not cover the configured domain set {:?} \
— discarding stale cache and re-provisioning",
self.cache_dir, self.domains
);
return Ok(false);
}
let now = SystemTime::now();
let time_remaining = expiry.duration_since(now).unwrap_or(Duration::ZERO);
if expiry <= now || time_remaining <= RENEW_THRESHOLD {
warn!(
"[acme] Cached certificate expires in {:.1} days — triggering renewal",
time_remaining.as_secs_f64() / 86400.0
);
return Ok(false);
}
info!(
"[acme] Loaded cached certificate (expires in {:.1} days)",
time_remaining.as_secs_f64() / 86400.0
);
let certified_key = Self::build_certified_key(certs, key)?;
self.resolver.update_cert(certified_key);
Ok(true)
}
fn check_cert_expiry(certs: &[CertificateDer<'static>]) -> Option<SystemTime> {
let first = certs.first()?;
min_der::parse_not_after(first.as_ref())
.inspect_err(|e| warn!("[acme] Failed to parse cached certificate: {e}"))
.ok()
}
fn cert_matches_domains(certs: &[CertificateDer<'static>], domains: &[String]) -> bool {
let Some(first) = certs.first() else {
return false;
};
let Ok(cert) = EndEntityCert::try_from(first) else {
return false;
};
let san_names: Vec<String> = cert
.valid_dns_names()
.map(str::to_ascii_lowercase)
.collect();
!san_names.is_empty()
&& domains
.iter()
.all(|d| san_names.contains(&d.to_ascii_lowercase()))
}
fn load_cached_certs_and_key(
&self,
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>), AcmeError> {
let cert_path = self.cache_dir.join("domain.crt");
let key_path = self.cache_dir.join("domain.key");
let cert_pem = fs::read_to_string(cert_path)?;
let key_pem = fs::read_to_string(key_path)?;
let mut cert_reader = std::io::BufReader::new(cert_pem.as_bytes());
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut cert_reader)
.filter_map(std::result::Result::ok)
.collect();
let mut key_reader = std::io::BufReader::new(key_pem.as_bytes());
let key =
rustls_pemfile::private_key(&mut key_reader)?.ok_or(AcmeError::MissingPrivateKey)?;
Ok((certs, key))
}
fn save_certs_and_key(&self, cert_pem: &str, key_pem: &str) -> Result<(), AcmeError> {
fs::write(self.cache_dir.join("domain.crt"), cert_pem)?;
let key_path = self.cache_dir.join("domain.key");
write_private_file(&key_path, key_pem.as_bytes())?;
Ok(())
}
fn build_certified_key(
certs: Vec<CertificateDer<'static>>,
key: PrivateKeyDer<'static>,
) -> Result<CertifiedKey, AcmeError> {
let provider = rustls::crypto::aws_lc_rs::default_provider();
let signing_key = provider
.key_provider
.load_private_key(key)
.map_err(|e| AcmeError::TlsKeyLoad(e.to_string()))?;
Ok(CertifiedKey::new(certs, signing_key))
}
async fn provision_cert(
&self,
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>), AcmeError> {
let _guard = self.provisioning.lock().await;
let directory_url = if self.is_staging {
"https://acme-staging-v02.api.letsencrypt.org/directory"
} else {
"https://acme-v02.api.letsencrypt.org/directory"
};
let account = self.get_or_create_account(directory_url).await?;
let identifiers: Vec<Identifier> = self
.domains
.iter()
.map(|d| Identifier::Dns(d.clone()))
.collect();
let new_order = NewOrder::new(&identifiers);
let mut order = account.new_order(&new_order).await?;
let mut tokens_to_unregister: Vec<String> = Vec::new();
{
let mut auths = order.authorizations();
while let Some(auth_res) = auths.next().await {
let mut auth = auth_res?;
let mut challenge = auth.challenge(ChallengeType::Http01).ok_or_else(|| {
AcmeError::Io(std::io::Error::other(
"No HTTP-01 challenge offered by CA — ensure port 80 is reachable",
))
})?;
let key_auth = challenge.key_authorization().as_str().to_string();
let token = challenge.token.clone();
register_challenge(token.clone(), key_auth);
tokens_to_unregister.push(token);
challenge.set_ready().await?;
}
}
let status = order
.poll_ready(&instant_acme::RetryPolicy::default())
.await?;
for token in &tokens_to_unregister {
unregister_challenge(token);
}
if status == OrderStatus::Invalid {
return Err(AcmeError::OrderInvalid);
}
let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?;
let cert_params = CertificateParams::new(self.domains.clone())?;
let csr = cert_params.serialize_request(&key_pair)?;
order.finalize_csr(csr.der().as_ref()).await?;
let cert_chain_pem = order
.poll_certificate(&instant_acme::RetryPolicy::default())
.await?;
let private_key_pem = key_pair.serialize_pem();
if let Err(e) = self.save_certs_and_key(&cert_chain_pem, &private_key_pem) {
error!("[acme] Failed to persist certificate to cache directory: {e}");
}
let mut cert_reader = std::io::BufReader::new(cert_chain_pem.as_bytes());
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut cert_reader)
.filter_map(std::result::Result::ok)
.collect();
let mut key_reader = std::io::BufReader::new(private_key_pem.as_bytes());
let key =
rustls_pemfile::private_key(&mut key_reader)?.ok_or(AcmeError::MissingPrivateKey)?;
Ok((certs, key))
}
async fn get_or_create_account(&self, directory_url: &str) -> Result<Account, AcmeError> {
let env_suffix = if self.is_staging { "staging" } else { "prod" };
let account_path = self.cache_dir.join(format!("account-{env_suffix}.json"));
if account_path.exists() {
match fs::read(&account_path) {
Ok(creds_bytes) => {
match serde_json::from_slice::<AccountCredentials>(&creds_bytes) {
Ok(creds) => {
let builder = Account::builder()?;
match builder.from_credentials(creds).await {
Ok(account) => {
info!("[acme] Reusing cached ACME account ({env_suffix})");
return Ok(account);
}
Err(e) => {
warn!(
"[acme] Cached account credentials invalid, creating new: {e}"
);
}
}
}
Err(e) => warn!("[acme] Failed to parse cached account credentials: {e}"),
}
}
Err(e) => warn!("[acme] Failed to read account credentials file: {e}"),
}
}
info!("[acme] Registering new ACME account with Let's Encrypt ({env_suffix})");
let contact = [format!("mailto:{}", self.email)];
let contact_refs: Vec<&str> = contact.iter().map(String::as_str).collect();
let builder = Account::builder()?;
let (account, creds) = builder
.create(
&NewAccount {
contact: &contact_refs,
terms_of_service_agreed: true,
only_return_existing: false,
},
directory_url.to_string(),
None,
)
.await?;
let creds_bytes = serde_json::to_vec(&creds)?;
if let Err(e) = write_private_file(&account_path, &creds_bytes) {
warn!("[acme] Failed to cache account credentials: {e}");
}
Ok(account)
}
}
mod min_der {
use std::time::{Duration, SystemTime};
fn read_tlv(buf: &[u8], pos: usize) -> Result<(u8, &[u8], usize), &'static str> {
let tag = *buf.get(pos).ok_or("truncated DER: missing tag")?;
let len_byte = *buf.get(pos + 1).ok_or("truncated DER: missing length")?;
let (len, header_len) = if len_byte & 0x80 == 0 {
(usize::from(len_byte), 2usize)
} else {
let n = usize::from(len_byte & 0x7f);
if n == 0 || n > 4 {
return Err("unsupported DER length encoding");
}
let start = pos + 2;
let bytes = buf
.get(start..start + n)
.ok_or("truncated DER: missing length bytes")?;
let mut len = 0usize;
for &b in bytes {
len = len
.checked_shl(8)
.and_then(|v| v.checked_add(usize::from(b)))
.ok_or("DER length overflow")?;
}
(len, 2 + n)
};
let content_start = pos + header_len;
let content_end = content_start
.checked_add(len)
.ok_or("DER length overflow")?;
let content = buf
.get(content_start..content_end)
.ok_or("truncated DER: content shorter than declared length")?;
Ok((tag, content, content_end))
}
const TAG_SEQUENCE: u8 = 0x30;
const TAG_INTEGER: u8 = 0x02;
const TAG_CONTEXT_0: u8 = 0xA0;
const TAG_UTC_TIME: u8 = 0x17;
const TAG_GENERALIZED_TIME: u8 = 0x18;
pub(super) fn parse_not_after(cert_der: &[u8]) -> Result<SystemTime, &'static str> {
let (tag, cert_content, _) = read_tlv(cert_der, 0)?;
if tag != TAG_SEQUENCE {
return Err("not a DER SEQUENCE (Certificate)");
}
let (tag, tbs, _) = read_tlv(cert_content, 0)?;
if tag != TAG_SEQUENCE {
return Err("not a DER SEQUENCE (TBSCertificate)");
}
let (tag, _, next) = read_tlv(tbs, 0)?;
let pos = if tag == TAG_CONTEXT_0 { next } else { 0 };
let (tag, _, pos) = read_tlv(tbs, pos)?;
if tag != TAG_INTEGER {
return Err("expected serialNumber INTEGER");
}
let (tag, _, pos) = read_tlv(tbs, pos)?;
if tag != TAG_SEQUENCE {
return Err("expected signature AlgorithmIdentifier SEQUENCE");
}
let (tag, _, pos) = read_tlv(tbs, pos)?;
if tag != TAG_SEQUENCE {
return Err("expected issuer Name SEQUENCE");
}
let (tag, validity, _) = read_tlv(tbs, pos)?;
if tag != TAG_SEQUENCE {
return Err("expected validity SEQUENCE");
}
let (_, _, pos) = read_tlv(validity, 0)?;
let (tag, time, _) = read_tlv(validity, pos)?;
match tag {
TAG_UTC_TIME => parse_utc_time(time),
TAG_GENERALIZED_TIME => parse_generalized_time(time),
_ => Err("notAfter is neither UTCTime nor GeneralizedTime"),
}
}
fn parse_utc_time(b: &[u8]) -> Result<SystemTime, &'static str> {
if b.len() != 13 || b[12] != b'Z' {
return Err("malformed UTCTime");
}
let yy = two_digits(&b[0..2])?;
let year = i64::from(if yy >= 50 { 1900 + yy } else { 2000 + yy });
ymdhms_to_system_time(
year,
two_digits(&b[2..4])?,
two_digits(&b[4..6])?,
two_digits(&b[6..8])?,
two_digits(&b[8..10])?,
two_digits(&b[10..12])?,
)
}
fn parse_generalized_time(b: &[u8]) -> Result<SystemTime, &'static str> {
if b.len() != 15 || b[14] != b'Z' {
return Err("malformed GeneralizedTime");
}
let year = i64::from(two_digits(&b[0..2])?) * 100 + i64::from(two_digits(&b[2..4])?);
ymdhms_to_system_time(
year,
two_digits(&b[4..6])?,
two_digits(&b[6..8])?,
two_digits(&b[8..10])?,
two_digits(&b[10..12])?,
two_digits(&b[12..14])?,
)
}
fn two_digits(b: &[u8]) -> Result<u32, &'static str> {
let [hi, lo] = *b else {
return Err("expected two ASCII digits");
};
if !hi.is_ascii_digit() || !lo.is_ascii_digit() {
return Err("expected two ASCII digits");
}
Ok(u32::from(hi - b'0') * 10 + u32::from(lo - b'0'))
}
fn ymdhms_to_system_time(
year: i64,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
) -> Result<SystemTime, &'static str> {
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return Err("month/day out of range");
}
if hour > 23 || minute > 59 || second > 60 {
return Err("time-of-day out of range");
}
let days = days_from_civil(year, i64::from(month), i64::from(day));
let secs_of_day = i64::from(hour) * 3600 + i64::from(minute) * 60 + i64::from(second);
let total_secs = days
.checked_mul(86_400)
.and_then(|d| d.checked_add(secs_of_day))
.ok_or("date arithmetic overflow")?;
let total_secs = u64::try_from(total_secs).map_err(|_| "date before the Unix epoch")?;
Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(total_secs))
}
const fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = y - era * 400; let mp = (m + 9) % 12; let doy = (153 * mp + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn epoch_day_zero() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
}
#[test]
fn known_dates() {
assert_eq!(days_from_civil(2024, 1, 1), 19723);
assert_eq!(
days_from_civil(2024, 3, 1) - days_from_civil(2024, 2, 29),
1
);
}
#[test]
fn utc_time_roundtrip() {
let t = parse_utc_time(b"991231235959Z").unwrap();
let secs = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
assert_eq!(secs, 946_684_799);
}
#[test]
fn utc_time_y2k_pivot() {
assert!(parse_utc_time(b"490101000000Z").is_ok());
assert!(parse_utc_time(b"500101000000Z").is_err());
}
#[test]
fn generalized_time_roundtrip() {
let t = parse_generalized_time(b"20991231235959Z").unwrap();
let secs = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
assert_eq!(secs, 4_102_444_799);
}
#[test]
fn rejects_malformed_input() {
assert!(parse_utc_time(b"not-a-time!!!").is_err());
assert!(parse_generalized_time(b"short").is_err());
assert!(parse_not_after(b"").is_err());
assert!(parse_not_after(&[0x30, 0x00]).is_err());
}
#[test]
#[cfg(feature = "cert-gen")]
fn parses_notafter_from_a_real_certificate() {
use rcgen::{CertificateParams, KeyPair, PKCS_ECDSA_P256_SHA256};
let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let params = CertificateParams::new(vec!["example.com".to_string()]).unwrap();
let cert = params.self_signed(&key_pair).unwrap();
let parsed = parse_not_after(cert.der().as_ref()).unwrap();
let year_2170 = SystemTime::UNIX_EPOCH + Duration::from_hours(24 * 365 * 200);
assert!(
parsed > year_2170,
"expected a far-future notAfter, got {parsed:?}"
);
}
}
}