use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
use x509_parser::prelude::FromDer;
use crate::rate_limiter::RateLimiter;
static CA_RATE_LIMITERS: std::sync::OnceLock<std::sync::Mutex<HashMap<String, Arc<RateLimiter>>>> =
std::sync::OnceLock::new();
fn get_ca_rate_limiter(ca_url: &str, email: &str) -> Arc<RateLimiter> {
let map_mutex = CA_RATE_LIMITERS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let key = format!("{},{}", ca_url, email);
let mut map = map_mutex.lock().unwrap();
map.entry(key)
.or_insert_with(|| Arc::new(RateLimiter::new(10, Duration::from_secs(10))))
.clone()
}
use crate::account::{AcmeAccount, delete_account_locally, get_or_create_account, save_account};
use crate::acme_client::{
AcmeClient, ExternalAccountBinding, LETS_ENCRYPT_PRODUCTION, LETS_ENCRYPT_STAGING,
};
use crate::crypto::{
KeyType, encode_private_key_pem, generate_csr, generate_private_key,
parse_certs_from_pem_bundle,
};
use crate::error::{AcmeError, Error, Result};
use crate::solvers::{DistributedSolver, Solver};
use crate::storage::{Storage, issuer_key};
type NewAccountFunc = Arc<dyn Fn(&mut AcmeAccount) + Send + Sync>;
pub const DEFAULT_CA: &str = LETS_ENCRYPT_PRODUCTION;
pub const DEFAULT_TEST_CA: &str = LETS_ENCRYPT_STAGING;
const CHALLENGE_TYPE_HTTP01: &str = "http-01";
const CHALLENGE_TYPE_DNS01: &str = "dns-01";
const CHALLENGE_TYPE_TLSALPN01: &str = "tls-alpn-01";
const DEFAULT_CERT_OBTAIN_TIMEOUT: Duration = Duration::from_secs(90);
const DEFAULT_AUTHZ_POLL_TIMEOUT: Duration = Duration::from_secs(120);
const DEFAULT_ORDER_POLL_TIMEOUT: Duration = Duration::from_secs(120);
#[async_trait]
pub trait CertIssuer: Send + Sync {
async fn issue(&self, csr_der: &[u8], domains: &[String]) -> Result<IssuedCertificate>;
fn issuer_key(&self) -> String;
fn as_revoker(&self) -> Option<&dyn Revoker> {
None
}
}
#[async_trait]
pub trait Revoker: Send + Sync {
async fn revoke(&self, cert_pem: &[u8], reason: Option<u8>) -> Result<()>;
}
#[async_trait]
pub trait PreChecker: Send + Sync {
async fn pre_check(&self, names: &[String], interactive: bool) -> Result<()>;
}
#[async_trait]
pub trait Manager: Send + Sync {
async fn get_certificate(
&self,
server_name: &str,
) -> Result<Option<crate::certificates::Certificate>>;
}
#[derive(Debug, Clone)]
pub struct IssuedCertificate {
pub certificate_pem: Vec<u8>,
pub private_key_pem: Vec<u8>,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChainPreference {
pub smallest: Option<bool>,
#[serde(default)]
pub root_common_name: Vec<String>,
#[serde(default)]
pub any_common_name: Vec<String>,
}
pub struct AcmeIssuer {
pub ca: String,
pub test_ca: String,
pub email: String,
pub agreed: bool,
pub external_account: Option<ExternalAccountBinding>,
pub disable_http_challenge: bool,
pub disable_tlsalpn_challenge: bool,
pub dns01_solver: Option<Arc<dyn Solver>>,
pub http01_solver: Option<Arc<dyn Solver>>,
pub tlsalpn01_solver: Option<Arc<dyn Solver>>,
pub alt_http_port: Option<u16>,
pub alt_tlsalpn_port: Option<u16>,
pub listen_host: Option<String>,
pub disable_distributed_solvers: bool,
pub account_key_pem: Option<String>,
pub trusted_roots: Option<Vec<u8>>,
pub cert_key_type: KeyType,
pub preferred_chains: Option<ChainPreference>,
pub cert_obtain_timeout: Duration,
pub resolver: Option<String>,
pub new_account_func: Option<NewAccountFunc>,
pub not_before: Option<Duration>,
pub not_after: Option<Duration>,
pub profile: Option<String>,
pub storage: Arc<dyn Storage>,
client: Mutex<Option<AcmeClient>>,
test_client: Mutex<Option<AcmeClient>>,
account: Mutex<Option<AcmeAccount>>,
test_account: Mutex<Option<AcmeAccount>>,
}
impl std::fmt::Debug for AcmeIssuer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcmeIssuer")
.field("ca", &self.ca)
.field("test_ca", &self.test_ca)
.field("email", &self.email)
.field("agreed", &self.agreed)
.field("disable_http_challenge", &self.disable_http_challenge)
.field("disable_tlsalpn_challenge", &self.disable_tlsalpn_challenge)
.field("cert_key_type", &self.cert_key_type)
.field("preferred_chains", &self.preferred_chains)
.finish_non_exhaustive()
}
}
pub struct AcmeIssuerBuilder {
ca: String,
test_ca: String,
email: String,
agreed: bool,
external_account: Option<ExternalAccountBinding>,
disable_http_challenge: bool,
disable_tlsalpn_challenge: bool,
dns01_solver: Option<Arc<dyn Solver>>,
http01_solver: Option<Arc<dyn Solver>>,
tlsalpn01_solver: Option<Arc<dyn Solver>>,
alt_http_port: Option<u16>,
alt_tlsalpn_port: Option<u16>,
listen_host: Option<String>,
disable_distributed_solvers: bool,
account_key_pem: Option<String>,
trusted_roots: Option<Vec<u8>>,
cert_key_type: KeyType,
preferred_chains: Option<ChainPreference>,
cert_obtain_timeout: Duration,
resolver: Option<String>,
new_account_func: Option<NewAccountFunc>,
not_before: Option<Duration>,
not_after: Option<Duration>,
profile: Option<String>,
storage: Option<Arc<dyn Storage>>,
}
impl AcmeIssuerBuilder {
pub fn ca(mut self, ca: impl Into<String>) -> Self {
self.ca = ca.into();
self
}
pub fn test_ca(mut self, test_ca: impl Into<String>) -> Self {
self.test_ca = test_ca.into();
self
}
pub fn email(mut self, email: impl Into<String>) -> Self {
self.email = email.into();
self
}
pub fn agreed(mut self, agreed: bool) -> Self {
self.agreed = agreed;
self
}
pub fn external_account(mut self, eab: ExternalAccountBinding) -> Self {
self.external_account = Some(eab);
self
}
pub fn disable_http_challenge(mut self, disabled: bool) -> Self {
self.disable_http_challenge = disabled;
self
}
pub fn disable_tlsalpn_challenge(mut self, disabled: bool) -> Self {
self.disable_tlsalpn_challenge = disabled;
self
}
pub fn dns01_solver(mut self, solver: Arc<dyn Solver>) -> Self {
self.dns01_solver = Some(solver);
self
}
pub fn http01_solver(mut self, solver: Arc<dyn Solver>) -> Self {
self.http01_solver = Some(solver);
self
}
pub fn tlsalpn01_solver(mut self, solver: Arc<dyn Solver>) -> Self {
self.tlsalpn01_solver = Some(solver);
self
}
pub fn alt_http_port(mut self, port: u16) -> Self {
self.alt_http_port = Some(port);
self
}
pub fn alt_tlsalpn_port(mut self, port: u16) -> Self {
self.alt_tlsalpn_port = Some(port);
self
}
pub fn listen_host(mut self, host: impl Into<String>) -> Self {
self.listen_host = Some(host.into());
self
}
pub fn disable_distributed_solvers(mut self, disabled: bool) -> Self {
self.disable_distributed_solvers = disabled;
self
}
pub fn account_key_pem(mut self, pem: impl Into<String>) -> Self {
self.account_key_pem = Some(pem.into());
self
}
pub fn trusted_roots(mut self, roots: Vec<u8>) -> Self {
self.trusted_roots = Some(roots);
self
}
pub fn cert_key_type(mut self, key_type: KeyType) -> Self {
self.cert_key_type = key_type;
self
}
pub fn preferred_chains(mut self, pref: ChainPreference) -> Self {
self.preferred_chains = Some(pref);
self
}
pub fn cert_obtain_timeout(mut self, timeout: Duration) -> Self {
self.cert_obtain_timeout = timeout;
self
}
pub fn resolver(mut self, resolver: impl Into<String>) -> Self {
self.resolver = Some(resolver.into());
self
}
pub fn new_account_func(mut self, func: NewAccountFunc) -> Self {
self.new_account_func = Some(func);
self
}
pub fn not_before(mut self, offset: Duration) -> Self {
self.not_before = Some(offset);
self
}
pub fn not_after(mut self, offset: Duration) -> Self {
self.not_after = Some(offset);
self
}
pub fn profile(mut self, profile: impl Into<String>) -> Self {
self.profile = Some(profile.into());
self
}
pub fn storage(mut self, storage: Arc<dyn Storage>) -> Self {
self.storage = Some(storage);
self
}
pub fn build(self) -> AcmeIssuer {
let storage = self.storage.expect(
"AcmeIssuer requires a Storage implementation — call .storage() on the builder",
);
AcmeIssuer {
ca: self.ca,
test_ca: self.test_ca,
email: self.email,
agreed: self.agreed,
external_account: self.external_account,
disable_http_challenge: self.disable_http_challenge,
disable_tlsalpn_challenge: self.disable_tlsalpn_challenge,
dns01_solver: self.dns01_solver,
http01_solver: self.http01_solver,
tlsalpn01_solver: self.tlsalpn01_solver,
alt_http_port: self.alt_http_port,
alt_tlsalpn_port: self.alt_tlsalpn_port,
listen_host: self.listen_host,
disable_distributed_solvers: self.disable_distributed_solvers,
account_key_pem: self.account_key_pem,
trusted_roots: self.trusted_roots,
cert_key_type: self.cert_key_type,
preferred_chains: self.preferred_chains,
cert_obtain_timeout: self.cert_obtain_timeout,
resolver: self.resolver,
new_account_func: self.new_account_func,
not_before: self.not_before,
not_after: self.not_after,
profile: self.profile,
storage,
client: Mutex::new(None),
test_client: Mutex::new(None),
account: Mutex::new(None),
test_account: Mutex::new(None),
}
}
}
impl AcmeIssuer {
pub fn builder() -> AcmeIssuerBuilder {
AcmeIssuerBuilder {
ca: DEFAULT_CA.to_owned(),
test_ca: DEFAULT_TEST_CA.to_owned(),
email: String::new(),
agreed: false,
external_account: None,
disable_http_challenge: false,
disable_tlsalpn_challenge: false,
dns01_solver: None,
http01_solver: None,
tlsalpn01_solver: None,
alt_http_port: None,
alt_tlsalpn_port: None,
listen_host: None,
disable_distributed_solvers: false,
account_key_pem: None,
trusted_roots: None,
cert_key_type: KeyType::default(),
preferred_chains: None,
cert_obtain_timeout: DEFAULT_CERT_OBTAIN_TIMEOUT,
resolver: None,
new_account_func: None,
not_before: None,
not_after: None,
profile: None,
storage: None,
}
}
fn using_test_ca(&self, ca_url: &str) -> bool {
ca_url == self.test_ca && self.ca != self.test_ca
}
async fn get_client(&self, use_test: bool) -> Result<AcmeClient> {
let ca_url = if use_test { &self.test_ca } else { &self.ca };
let mutex = if use_test {
&self.test_client
} else {
&self.client
};
{
let guard = mutex.lock().await;
if let Some(ref client) = *guard {
let _ = client;
}
}
{
let guard = mutex.lock().await;
if guard.is_some() {
drop(guard);
}
}
info!(ca = %ca_url, "initialising ACME client");
let client = AcmeClient::new(ca_url).await?;
let mut guard = mutex.lock().await;
*guard = Some(AcmeClient::new(ca_url).await?);
Ok(client)
}
async fn get_account(&self, client: &AcmeClient, use_test: bool) -> Result<AcmeAccount> {
let ca_url = if use_test { &self.test_ca } else { &self.ca };
let mutex = if use_test {
&self.test_account
} else {
&self.account
};
{
let guard = mutex.lock().await;
if let Some(ref acct) = *guard {
return Ok(acct.clone());
}
}
let lock_key = format!("acme_account_{}", issuer_key(ca_url));
self.storage.lock(&lock_key).await?;
let result = self.get_account_inner(client, ca_url).await;
self.storage.unlock(&lock_key).await?;
let acct = result?;
let mut guard = mutex.lock().await;
*guard = Some(acct.clone());
Ok(acct)
}
async fn get_account_inner(&self, client: &AcmeClient, ca_url: &str) -> Result<AcmeAccount> {
let (mut acct, is_new) = get_or_create_account(
self.storage.as_ref(),
ca_url,
&self.email,
KeyType::EcdsaP256, )
.await?;
if is_new {
if let Some(ref func) = self.new_account_func {
func(&mut acct);
}
info!(
email = %self.email,
ca = %ca_url,
"registering new ACME account with CA"
);
let eab = self.external_account.clone();
let private_key = crate::crypto::decode_private_key_pem(&acct.private_key_pem)
.map_err(|e| AcmeError::Account(format!("failed to decode account key: {e}")))?;
let (resp, location) = client
.new_account(&private_key, &acct.contact, self.agreed, eab)
.await?;
acct.status = resp.status;
acct.location = location;
acct.terms_of_service_agreed = self.agreed;
save_account(self.storage.as_ref(), ca_url, &acct).await?;
info!(
location = %acct.location,
"ACME account registered and saved"
);
} else {
debug!(
location = %acct.location,
"using existing ACME account"
);
}
Ok(acct)
}
fn select_challenge<'a>(
&self,
challenges: &'a [crate::acme_client::AcmeChallenge],
) -> Result<&'a crate::acme_client::AcmeChallenge> {
if self.dns01_solver.is_some()
&& let Some(c) = challenges
.iter()
.find(|c| c.challenge_type == CHALLENGE_TYPE_DNS01)
{
return Ok(c);
}
if !self.disable_http_challenge
&& let Some(c) = challenges
.iter()
.find(|c| c.challenge_type == CHALLENGE_TYPE_HTTP01)
{
return Ok(c);
}
if !self.disable_tlsalpn_challenge
&& let Some(c) = challenges
.iter()
.find(|c| c.challenge_type == CHALLENGE_TYPE_TLSALPN01)
{
return Ok(c);
}
let available: Vec<&str> = challenges
.iter()
.map(|c| c.challenge_type.as_str())
.collect();
Err(AcmeError::Challenge {
challenge_type: "none".into(),
message: format!(
"no suitable challenge type found among {:?} \
(dns01_solver={}, http_disabled={}, tlsalpn_disabled={})",
available,
self.dns01_solver.is_some(),
self.disable_http_challenge,
self.disable_tlsalpn_challenge,
),
}
.into())
}
fn solver_for(&self, challenge_type: &str) -> Result<Arc<dyn Solver>> {
match challenge_type {
CHALLENGE_TYPE_DNS01 => self.dns01_solver.clone().ok_or_else(|| {
Error::Config("dns-01 challenge selected but no DNS solver is configured".into())
}),
CHALLENGE_TYPE_HTTP01 => self.http01_solver.clone().ok_or_else(|| {
Error::Config("http-01 challenge selected but no HTTP solver is configured".into())
}),
CHALLENGE_TYPE_TLSALPN01 => self.tlsalpn01_solver.clone().ok_or_else(|| {
Error::Config(
"tls-alpn-01 challenge selected but no TLS-ALPN solver is configured".into(),
)
}),
other => Err(Error::Config(format!(
"unsupported challenge type: {other}"
))),
}
}
async fn do_issue(
&self,
csr_der: &[u8],
domains: &[String],
use_test_ca: bool,
attempt: usize,
) -> Result<(IssuedCertificate, bool)> {
let ca_url = if use_test_ca { &self.test_ca } else { &self.ca };
let is_test = self.using_test_ca(ca_url);
debug!(
attempt = attempt,
domains = ?domains,
ca = %ca_url,
"ACME issuance attempt"
);
let limiter = get_ca_rate_limiter(ca_url, &self.email);
let waited = limiter.wait().await;
if !waited.is_zero() {
debug!(
waited_ms = waited.as_millis(),
ca = %ca_url,
"waited for per-CA rate limiter"
);
}
let client = self.get_client(use_test_ca).await?;
let acct = self.get_account(&client, use_test_ca).await?;
let account_key = crate::crypto::decode_private_key_pem(&acct.private_key_pem)
.map_err(|e| AcmeError::Account(format!("failed to decode account key: {e}")))?;
info!(
domains = ?domains,
ca = %ca_url,
account = %acct.location,
"starting ACME certificate issuance"
);
let order_result = client
.new_order(&account_key, &acct.location, domains)
.await;
let (order, order_url) = match order_result {
Ok(result) => result,
Err(ref e) if format!("{e}").contains("accountDoesNotExist") => {
warn!(
ca = %ca_url,
"account does not exist on CA, deleting local account and retrying"
);
self.delete_account_and_clear_cache(ca_url, &acct, use_test_ca)
.await?;
let new_acct = self.get_account(&client, use_test_ca).await?;
let new_account_key = crate::crypto::decode_private_key_pem(
&new_acct.private_key_pem,
)
.map_err(|e| AcmeError::Account(format!("failed to decode account key: {e}")))?;
client
.new_order(&new_account_key, &new_acct.location, domains)
.await?
}
Err(e) => return Err(e),
};
debug!(
order_url = %order_url,
status = %order.status,
authorizations = order.authorizations.len(),
"ACME order created"
);
for authz_url in &order.authorizations {
let authz = client
.get_authorization(&account_key, &acct.location, authz_url)
.await?;
if authz.status == "valid" {
debug!(
identifier = %authz.identifier.value,
"authorization already valid, skipping"
);
continue;
}
let challenge = self.select_challenge(&authz.challenges)?;
debug!(
identifier = %authz.identifier.value,
challenge_type = %challenge.challenge_type,
"selected challenge"
);
let key_auth = crate::acme_client::key_authorization(&challenge.token, &account_key)?;
let solver = self.solver_for(&challenge.challenge_type)?;
let solver: Arc<dyn Solver> = if !self.disable_distributed_solvers {
let prefix = issuer_key(ca_url);
Arc::new(DistributedSolver::with_prefix(
Box::new(SolverWrapper(solver)),
self.storage.clone(),
prefix,
))
} else {
solver
};
solver
.present(&authz.identifier.value, &challenge.token, &key_auth)
.await
.map_err(|e| AcmeError::Challenge {
challenge_type: challenge.challenge_type.clone(),
message: format!("failed to present challenge: {e}"),
})?;
if let Err(e) = solver
.wait(&authz.identifier.value, &challenge.token, &key_auth)
.await
{
let _ = solver
.cleanup(&authz.identifier.value, &challenge.token, &key_auth)
.await;
return Err(AcmeError::Challenge {
challenge_type: challenge.challenge_type.clone(),
message: format!("solver wait failed: {e}"),
}
.into());
}
client
.accept_challenge(&account_key, &acct.location, &challenge.url)
.await?;
let poll_result = client
.poll_authorization(
&account_key,
&acct.location,
authz_url,
DEFAULT_AUTHZ_POLL_TIMEOUT,
)
.await;
if let Err(cleanup_err) = solver
.cleanup(&authz.identifier.value, &challenge.token, &key_auth)
.await
{
warn!(
identifier = %authz.identifier.value,
error = %cleanup_err,
"failed to clean up challenge solver"
);
}
poll_result?;
debug!(
identifier = %authz.identifier.value,
"authorization validated"
);
}
let finalized = client
.finalize_order(&account_key, &acct.location, &order.finalize, csr_der)
.await?;
debug!(
order_url = %order_url,
status = %finalized.status,
"order finalized"
);
let completed = if finalized.status == "valid" {
finalized
} else {
client
.poll_order(
&account_key,
&acct.location,
&order_url,
DEFAULT_ORDER_POLL_TIMEOUT,
)
.await?
};
let cert_url = completed.certificate.ok_or_else(|| {
AcmeError::Certificate("order is valid but has no certificate URL".into())
})?;
let cert_pem = client
.download_certificate(&account_key, &acct.location, &cert_url)
.await?;
info!(
domains = ?domains,
ca = %ca_url,
cert_url = %cert_url,
"certificate issued successfully"
);
let final_pem = if let Some(ref pref) = self.preferred_chains {
self.select_preferred_chain(&cert_pem, pref)
} else {
cert_pem.clone()
};
let metadata = serde_json::json!({
"ca": ca_url,
"account_location": acct.location,
"order_url": order_url,
"certificate_url": cert_url,
});
let issued = IssuedCertificate {
certificate_pem: final_pem.into_bytes(),
private_key_pem: Vec::new(), metadata,
};
Ok((issued, is_test))
}
fn select_preferred_chain(&self, cert_pem: &str, pref: &ChainPreference) -> String {
let cert_ders = match parse_certs_from_pem_bundle(cert_pem) {
Ok(ders) => ders,
Err(e) => {
warn!(error = %e, "failed to parse certificate chain for preference selection");
return cert_pem.to_owned();
}
};
if cert_ders.is_empty() {
return cert_pem.to_owned();
}
fn issuer_cn_from_der(der: &[u8]) -> Option<String> {
let (_, cert) = x509_parser::certificate::X509Certificate::from_der(der).ok()?;
cert.issuer()
.iter_common_name()
.next()
.and_then(|attr| attr.as_str().ok())
.map(|s| s.to_owned())
}
if !pref.any_common_name.is_empty() {
for pref_cn in &pref.any_common_name {
for der in &cert_ders {
if let Some(cn) = issuer_cn_from_der(der)
&& cn == *pref_cn
{
debug!(
preferred_cn = %pref_cn,
"found certificate matching any_common_name preference"
);
return cert_pem.to_owned();
}
}
}
}
if !pref.root_common_name.is_empty()
&& let Some(root_der) = cert_ders.last()
&& let Some(cn) = issuer_cn_from_der(root_der)
{
for pref_cn in &pref.root_common_name {
if cn == *pref_cn {
debug!(
preferred_cn = %pref_cn,
"found certificate matching root_common_name preference"
);
return cert_pem.to_owned();
}
}
}
if let Some(want_smallest) = pref.smallest {
let _pem_items = match pem::parse_many(cert_pem) {
Ok(items) if items.len() > 1 => items,
_ => return cert_pem.to_owned(),
};
let mut chains: Vec<String> = vec![cert_pem.to_owned()];
chains.sort_by(|a, b| {
if want_smallest {
a.len().cmp(&b.len())
} else {
b.len().cmp(&a.len())
}
});
debug!(
smallest = want_smallest,
chain_count = chains.len(),
"applied chain size sorting preference"
);
return chains
.into_iter()
.next()
.unwrap_or_else(|| cert_pem.to_owned());
}
cert_pem.to_owned()
}
async fn delete_account_and_clear_cache(
&self,
ca_url: &str,
acct: &AcmeAccount,
use_test: bool,
) -> Result<()> {
delete_account_locally(self.storage.as_ref(), ca_url, acct).await?;
let mutex = if use_test {
&self.test_account
} else {
&self.account
};
let mut guard = mutex.lock().await;
*guard = None;
Ok(())
}
}
#[async_trait]
impl CertIssuer for AcmeIssuer {
async fn issue(&self, csr_der: &[u8], domains: &[String]) -> Result<IssuedCertificate> {
let mut attempt: usize = 0;
attempt += 1;
info!(attempt = attempt, domains = ?domains, "starting certificate issuance");
let result = self.do_issue(csr_der, domains, false, attempt).await;
match result {
Ok((cert, _used_test)) => return Ok(cert),
Err(first_err) => {
if self.ca != self.test_ca {
warn!(
error = %first_err,
"production CA issuance failed, trying test CA to verify setup"
);
attempt += 1;
info!(attempt = attempt, "retrying with test CA");
match self.do_issue(csr_der, domains, true, attempt).await {
Ok((_test_cert, _)) => {
info!("test CA issuance succeeded; retrying production CA once more");
attempt += 1;
info!(attempt = attempt, "retrying production CA");
let (cert, _) = self.do_issue(csr_der, domains, false, attempt).await?;
return Ok(cert);
}
Err(test_err) => {
warn!(
error = %test_err,
"test CA issuance also failed"
);
return Err(first_err);
}
}
}
return Err(first_err);
}
}
}
fn issuer_key(&self) -> String {
issuer_key(&self.ca)
}
fn as_revoker(&self) -> Option<&dyn Revoker> {
Some(self)
}
}
#[async_trait]
impl Revoker for AcmeIssuer {
async fn revoke(&self, cert_pem: &[u8], reason: Option<u8>) -> Result<()> {
let cert_pem_str = std::str::from_utf8(cert_pem)
.map_err(|e| AcmeError::Certificate(format!("cert PEM is not valid UTF-8: {e}")))?;
let cert_ders = parse_certs_from_pem_bundle(cert_pem_str)?;
if cert_ders.is_empty() {
return Err(
AcmeError::Certificate("no certificates found in PEM bundle".into()).into(),
);
}
let leaf_der = &cert_ders[0];
let client = self.get_client(false).await?;
let acct = self.get_account(&client, false).await?;
let account_key = crate::crypto::decode_private_key_pem(&acct.private_key_pem)
.map_err(|e| AcmeError::Account(format!("failed to decode account key: {e}")))?;
client
.revoke_certificate(&account_key, &acct.location, leaf_der, reason)
.await?;
info!("certificate revoked");
Ok(())
}
}
impl AcmeIssuer {
pub async fn issue_for_domains(&self, domains: &[String]) -> Result<IssuedCertificate> {
if domains.is_empty() {
return Err(Error::Config(
"at least one domain is required for certificate issuance".into(),
));
}
let private_key = generate_private_key(self.cert_key_type)?;
let private_key_pem = encode_private_key_pem(&private_key)?;
let csr_der = generate_csr(&private_key, domains, false)?;
let mut issued = self.issue(&csr_der, domains).await?;
issued.private_key_pem = private_key_pem.into_bytes();
Ok(issued)
}
}
struct SolverWrapper(Arc<dyn Solver>);
#[async_trait]
impl Solver for SolverWrapper {
async fn present(&self, domain: &str, token: &str, key_auth: &str) -> Result<()> {
self.0.present(domain, token, key_auth).await
}
async fn wait(&self, domain: &str, token: &str, key_auth: &str) -> Result<()> {
self.0.wait(domain, token, key_auth).await
}
async fn cleanup(&self, domain: &str, token: &str, key_auth: &str) -> Result<()> {
self.0.cleanup(domain, token, key_auth).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_issuer_key_lets_encrypt() {
let ik = issuer_key(LETS_ENCRYPT_PRODUCTION);
assert!(ik.contains("acme-v02.api.letsencrypt.org"));
}
#[test]
fn test_issuer_key_staging() {
let ik = issuer_key(LETS_ENCRYPT_STAGING);
assert!(ik.contains("acme-staging-v02.api.letsencrypt.org"));
}
#[test]
fn test_chain_preference_default() {
let pref = ChainPreference::default();
assert!(pref.smallest.is_none());
assert!(pref.root_common_name.is_empty());
assert!(pref.any_common_name.is_empty());
}
#[test]
fn test_issued_certificate_fields() {
let ic = IssuedCertificate {
certificate_pem: b"cert data".to_vec(),
private_key_pem: b"key data".to_vec(),
metadata: serde_json::json!({"url": "https://example.com"}),
};
assert_eq!(ic.certificate_pem, b"cert data");
assert_eq!(ic.private_key_pem, b"key data");
assert!(ic.metadata.is_object());
}
#[test]
fn test_challenge_type_constants() {
assert_eq!(CHALLENGE_TYPE_HTTP01, "http-01");
assert_eq!(CHALLENGE_TYPE_DNS01, "dns-01");
assert_eq!(CHALLENGE_TYPE_TLSALPN01, "tls-alpn-01");
}
#[test]
fn test_default_ca_urls() {
assert_eq!(DEFAULT_CA, LETS_ENCRYPT_PRODUCTION);
assert_eq!(DEFAULT_TEST_CA, LETS_ENCRYPT_STAGING);
}
}