use std::sync::OnceLock;
use std::time::Duration;
#[cfg(feature = "aws-lc-rs")]
use aws_lc_rs as crypto_provider;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Utc};
use crypto_provider::rand::SystemRandom;
use crypto_provider::signature::{self, ECDSA_P256_SHA256_FIXED_SIGNING};
use reqwest::header::HeaderValue;
#[cfg(all(feature = "ring", not(feature = "aws-lc-rs")))]
use ring as crypto_provider;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use tracing::{debug, warn};
use crate::crypto::PrivateKey;
use crate::error::{AcmeError, Result};
static USER_AGENT: OnceLock<String> = OnceLock::new();
pub fn set_user_agent(ua: impl Into<String>) {
USER_AGENT.set(ua.into()).ok();
}
fn get_user_agent() -> &'static str {
USER_AGENT.get().map(|s| s.as_str()).unwrap_or("certon/0.1")
}
pub const LETS_ENCRYPT_PRODUCTION: &str = "https://acme-v02.api.letsencrypt.org/directory";
pub const LETS_ENCRYPT_STAGING: &str = "https://acme-staging-v02.api.letsencrypt.org/directory";
pub const ZEROSSL_PRODUCTION: &str = "https://acme.zerossl.com/v2/DV90";
pub const GOOGLE_TRUST_STAGING: &str = "https://dv.acme-v02.test-api.pki.goog/directory";
pub const GOOGLE_TRUST_PRODUCTION: &str = "https://dv.acme-v02.api.pki.goog/directory";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcmeDirectory {
#[serde(rename = "newNonce")]
pub new_nonce: String,
#[serde(rename = "newAccount")]
pub new_account: String,
#[serde(rename = "newOrder")]
pub new_order: String,
#[serde(rename = "newAuthz")]
pub new_authz: Option<String>,
#[serde(rename = "revokeCert")]
pub revoke_cert: String,
#[serde(rename = "keyChange")]
pub key_change: String,
#[serde(rename = "renewalInfo")]
pub renewal_info: Option<String>,
pub meta: Option<DirectoryMeta>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectoryMeta {
#[serde(rename = "termsOfService")]
pub terms_of_service: Option<String>,
pub website: Option<String>,
#[serde(rename = "caaIdentities")]
pub caa_identities: Option<Vec<String>>,
#[serde(rename = "externalAccountRequired")]
pub external_account_required: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RenewalInfo {
#[serde(rename = "suggestedWindow")]
pub suggested_window: Option<RenewalWindow>,
#[serde(rename = "explanationURL")]
pub explanation_url: Option<String>,
#[serde(rename = "retryAfter")]
pub retry_after: Option<u64>,
#[serde(skip)]
pub selected_time: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenewalWindow {
pub start: String,
pub end: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcmeOrder {
#[serde(default)]
pub status: String,
#[serde(default)]
pub identifiers: Vec<AcmeIdentifier>,
#[serde(default)]
pub authorizations: Vec<String>,
#[serde(default)]
pub finalize: String,
#[serde(default)]
pub certificate: Option<String>,
#[serde(default)]
pub expires: Option<String>,
#[serde(default)]
pub error: Option<AcmeProblem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcmeAuthorization {
#[serde(default)]
pub status: String,
pub identifier: AcmeIdentifier,
#[serde(default)]
pub challenges: Vec<AcmeChallenge>,
#[serde(default)]
pub expires: Option<String>,
#[serde(default)]
pub wildcard: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcmeChallenge {
#[serde(rename = "type")]
pub challenge_type: String,
pub url: String,
#[serde(default)]
pub token: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub validated: Option<String>,
#[serde(default)]
pub error: Option<AcmeProblem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcmeIdentifier {
#[serde(rename = "type")]
pub id_type: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcmeProblem {
#[serde(rename = "type", default)]
pub problem_type: String,
#[serde(default)]
pub detail: String,
#[serde(default)]
pub status: Option<u16>,
#[serde(default)]
pub subproblems: Option<Vec<AcmeProblem>>,
}
impl std::fmt::Display for AcmeProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.detail)?;
if !self.problem_type.is_empty() {
write!(f, " ({})", self.problem_type)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountResponse {
#[serde(default)]
pub status: String,
#[serde(default)]
pub contact: Vec<String>,
#[serde(rename = "termsOfServiceAgreed", default)]
pub terms_of_service_agreed: bool,
#[serde(default)]
pub orders: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ExternalAccountBinding {
pub kid: String,
pub hmac_key: Vec<u8>,
}
#[derive(Serialize)]
struct JwsProtected<'a> {
alg: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
jwk: Option<Jwk>,
#[serde(skip_serializing_if = "Option::is_none")]
kid: Option<&'a str>,
nonce: &'a str,
url: &'a str,
}
#[derive(Clone, Serialize)]
struct Jwk {
alg: &'static str,
crv: &'static str,
kty: &'static str,
#[serde(rename = "use")]
u: &'static str,
x: String,
y: String,
}
#[derive(Serialize)]
struct JwsBody {
protected: String,
payload: String,
signature: String,
}
fn jwk_from_key(key: &PrivateKey) -> Result<Jwk> {
let key_pair =
crate::crypto::ecdsa_from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, key.pkcs8_der())
.map_err(|e| AcmeError::Account(format!("failed to load ECDSA key pair: {e}")))?;
let public_key = signature::KeyPair::public_key(&key_pair).as_ref();
if public_key.len() != 65 || public_key[0] != 0x04 {
return Err(AcmeError::Account("unexpected ECDSA P-256 public key format".into()).into());
}
let (x, y) = public_key[1..].split_at(32);
Ok(Jwk {
alg: "ES256",
crv: "P-256",
kty: "EC",
u: "sig",
x: URL_SAFE_NO_PAD.encode(x),
y: URL_SAFE_NO_PAD.encode(y),
})
}
fn jwk_thumbprint(jwk: &Jwk) -> String {
#[derive(Serialize)]
struct JwkThumb<'a> {
crv: &'a str,
kty: &'a str,
x: &'a str,
y: &'a str,
}
let thumb = JwkThumb {
crv: jwk.crv,
kty: jwk.kty,
x: &jwk.x,
y: &jwk.y,
};
let json = serde_json::to_vec(&thumb).expect("JWK thumbprint serialization should not fail");
let digest = Sha256::digest(&json);
URL_SAFE_NO_PAD.encode(digest)
}
pub fn key_authorization(token: &str, account_key: &PrivateKey) -> Result<String> {
let jwk = jwk_from_key(account_key)?;
let thumbprint = jwk_thumbprint(&jwk);
Ok(format!("{token}.{thumbprint}"))
}
pub fn key_authorization_sha256(token: &str, account_key: &PrivateKey) -> Result<Vec<u8>> {
let ka = key_authorization(token, account_key)?;
Ok(Sha256::digest(ka.as_bytes()).to_vec())
}
fn sign_with_key(key: &PrivateKey, data: &[u8]) -> Result<Vec<u8>> {
let rng = SystemRandom::new();
let key_pair =
crate::crypto::ecdsa_from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, key.pkcs8_der())
.map_err(|e| {
AcmeError::Account(format!("failed to load ECDSA key pair for signing: {e}"))
})?;
let sig = key_pair
.sign(&rng, data)
.map_err(|e| AcmeError::Account(format!("ECDSA signing failed: {e}")))?;
Ok(sig.as_ref().to_vec())
}
fn build_jws_body(
key: &PrivateKey,
kid: Option<&str>,
nonce: &str,
url: &str,
payload: Option<&str>,
) -> Result<Vec<u8>> {
let jwk = match kid {
None => Some(jwk_from_key(key)?),
Some(_) => None,
};
let protected = JwsProtected {
alg: "ES256",
jwk,
kid,
nonce,
url,
};
let protected_json = serde_json::to_vec(&protected)
.map_err(|e| AcmeError::Account(format!("failed to encode JWS protected header: {e}")))?;
let protected_b64 = URL_SAFE_NO_PAD.encode(&protected_json);
let payload_b64 = match payload {
Some(p) => URL_SAFE_NO_PAD.encode(p.as_bytes()),
None => String::new(),
};
let signing_input = format!("{protected_b64}.{payload_b64}");
let signature = sign_with_key(key, signing_input.as_bytes())?;
let signature_b64 = URL_SAFE_NO_PAD.encode(&signature);
let body = JwsBody {
protected: protected_b64,
payload: payload_b64,
signature: signature_b64,
};
serde_json::to_vec(&body)
.map_err(|e| AcmeError::Account(format!("failed to serialize JWS body: {e}")).into())
}
pub struct AcmeClient {
http: reqwest::Client,
directory: AcmeDirectory,
nonce: Mutex<Option<String>>,
}
impl std::fmt::Debug for AcmeClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcmeClient")
.field("directory", &self.directory)
.finish_non_exhaustive()
}
}
impl AcmeClient {
pub async fn new(directory_url: &str) -> Result<Self> {
crate::install_default_crypto_provider();
if let Ok(parsed) = url::Url::parse(directory_url)
&& parsed.scheme() == "http"
{
let host = parsed.host_str().unwrap_or("");
let is_local = host == "localhost"
|| host == "127.0.0.1"
|| host == "[::1]"
|| host == "::1"
|| host.ends_with(".internal")
|| host.ends_with(".localhost");
if !is_local {
return Err(AcmeError::Directory(format!(
"ACME directory URL must use HTTPS (got {directory_url}); \
HTTP is only allowed for localhost/internal hosts"
))
.into());
}
}
debug!(directory_url, "fetching ACME directory");
let http = reqwest::Client::builder()
.user_agent(get_user_agent())
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| AcmeError::Directory(format!("failed to build HTTP client: {e}")))?;
let resp = http
.get(directory_url)
.send()
.await
.map_err(|e| AcmeError::Directory(format!("failed to fetch directory: {e}")))?;
if !resp.status().is_success() {
return Err(
AcmeError::Directory(format!("directory returned HTTP {}", resp.status())).into(),
);
}
let directory: AcmeDirectory = resp
.json()
.await
.map_err(|e| AcmeError::Directory(format!("failed to parse directory JSON: {e}")))?;
debug!(
new_nonce = %directory.new_nonce,
new_account = %directory.new_account,
new_order = %directory.new_order,
"ACME directory loaded"
);
let nonce = Self::fetch_nonce_from(&http, &directory.new_nonce).await?;
Ok(Self {
http,
directory,
nonce: Mutex::new(Some(nonce)),
})
}
pub fn directory(&self) -> &AcmeDirectory {
&self.directory
}
async fn get_nonce(&self) -> Result<String> {
{
let mut guard = self.nonce.lock().await;
if let Some(nonce) = guard.take() {
return Ok(nonce);
}
}
Self::fetch_nonce_from(&self.http, &self.directory.new_nonce).await
}
async fn fetch_nonce_from(http: &reqwest::Client, new_nonce_url: &str) -> Result<String> {
debug!("fetching new nonce via HEAD");
let resp = http
.head(new_nonce_url)
.send()
.await
.map_err(|e| AcmeError::Nonce(format!("HEAD newNonce failed: {e}")))?;
extract_nonce(&resp).ok_or_else(|| {
AcmeError::Nonce("no Replay-Nonce header in HEAD response".into()).into()
})
}
async fn cache_nonce(&self, resp: &reqwest::Response) {
if let Some(nonce) = extract_nonce(resp) {
let mut guard = self.nonce.lock().await;
*guard = Some(nonce);
}
}
async fn acme_post(
&self,
account_key: &PrivateKey,
kid: Option<&str>,
url: &str,
payload: Option<&str>,
) -> Result<reqwest::Response> {
for attempt in 0..2 {
let nonce = self.get_nonce().await?;
let body = build_jws_body(account_key, kid, &nonce, url, payload)?;
let resp = self
.http
.post(url)
.header("Content-Type", "application/jose+json")
.body(body)
.send()
.await
.map_err(|e| AcmeError::Order(format!("ACME POST to {url} failed: {e}")))?;
self.cache_nonce(&resp).await;
if resp.status().as_u16() == 400 && attempt == 0 {
let resp_bytes = resp
.bytes()
.await
.map_err(|e| AcmeError::Nonce(format!("failed to read response body: {e}")))?;
if let Ok(problem) = serde_json::from_slice::<AcmeProblem>(&resp_bytes)
&& problem.problem_type.contains("badNonce")
{
warn!("bad nonce, retrying with fresh nonce");
continue;
}
let problem: std::result::Result<AcmeProblem, _> =
serde_json::from_slice(&resp_bytes);
let detail = match problem {
Ok(p) => format!("{p}"),
Err(_) => String::from_utf8_lossy(&resp_bytes).into_owned(),
};
return Err(AcmeError::Order(format!(
"ACME request to {url} failed (HTTP 400): {detail}"
))
.into());
}
return Ok(resp);
}
Err(AcmeError::Nonce("failed after badNonce retry".into()).into())
}
async fn acme_post_json<T: serde::de::DeserializeOwned>(
&self,
account_key: &PrivateKey,
kid: Option<&str>,
url: &str,
payload: Option<&str>,
) -> Result<(T, Option<String>)> {
let resp = self.acme_post(account_key, kid, url, payload).await?;
let status = resp.status();
let location = resp
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
if !status.is_success() {
let body = resp.bytes().await.unwrap_or_default();
let detail = match serde_json::from_slice::<AcmeProblem>(&body) {
Ok(p) => format!("{p}"),
Err(_) => String::from_utf8_lossy(&body).into_owned(),
};
return Err(
AcmeError::Order(format!("ACME {url} returned HTTP {status}: {detail}")).into(),
);
}
let body = resp
.bytes()
.await
.map_err(|e| AcmeError::Order(format!("failed to read response body: {e}")))?;
let parsed: T = serde_json::from_slice(&body)
.map_err(|e| AcmeError::Order(format!("failed to parse response JSON: {e}")))?;
Ok((parsed, location))
}
pub async fn new_account(
&self,
account_key: &PrivateKey,
contact: &[String],
tos_agreed: bool,
eab: Option<ExternalAccountBinding>,
) -> Result<(AccountResponse, String)> {
debug!("registering new ACME account");
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NewAccountRequest {
terms_of_service_agreed: bool,
contact: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
external_account_binding: Option<serde_json::Value>,
}
let eab_value = match eab {
Some(eab_creds) => Some(build_eab_jws(
account_key,
&eab_creds,
&self.directory.new_account,
)?),
None => None,
};
let req = NewAccountRequest {
terms_of_service_agreed: tos_agreed,
contact: contact.to_vec(),
external_account_binding: eab_value,
};
let payload = serde_json::to_string(&req)
.map_err(|e| AcmeError::Account(format!("failed to serialize account request: {e}")))?;
let (acct_resp, location): (AccountResponse, _) = self
.acme_post_json(
account_key,
None,
&self.directory.new_account,
Some(&payload),
)
.await?;
let account_url = location.ok_or_else(|| {
AcmeError::Account("no Location header in account creation response".into())
})?;
debug!(
status = %acct_resp.status,
account_url = %account_url,
"ACME account registered"
);
Ok((acct_resp, account_url))
}
pub async fn find_account(
&self,
account_key: &PrivateKey,
) -> Result<Option<(AccountResponse, String)>> {
debug!("looking up existing ACME account");
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FindAccountRequest {
only_return_existing: bool,
}
let req = FindAccountRequest {
only_return_existing: true,
};
let payload = serde_json::to_string(&req)
.map_err(|e| AcmeError::Account(format!("failed to serialize find request: {e}")))?;
let nonce = self.get_nonce().await?;
let body = build_jws_body(
account_key,
None,
&nonce,
&self.directory.new_account,
Some(&payload),
)?;
let resp = self
.http
.post(&self.directory.new_account)
.header("Content-Type", "application/jose+json")
.body(body)
.send()
.await
.map_err(|e| AcmeError::Account(format!("account lookup failed: {e}")))?;
self.cache_nonce(&resp).await;
if resp.status().as_u16() == 400 {
let body_bytes = resp.bytes().await.unwrap_or_default();
if let Ok(problem) = serde_json::from_slice::<AcmeProblem>(&body_bytes)
&& problem.problem_type.contains("accountDoesNotExist")
{
return Ok(None);
}
return Err(AcmeError::Account(format!(
"account lookup returned HTTP 400: {}",
String::from_utf8_lossy(&body_bytes)
))
.into());
}
if !resp.status().is_success() {
return Err(AcmeError::Account(format!(
"account lookup returned HTTP {}",
resp.status()
))
.into());
}
let account_url = resp
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
let body_bytes = resp
.bytes()
.await
.map_err(|e| AcmeError::Account(format!("failed to read response: {e}")))?;
let acct_resp: AccountResponse = serde_json::from_slice(&body_bytes)
.map_err(|e| AcmeError::Account(format!("failed to parse account response: {e}")))?;
match account_url {
Some(url) => Ok(Some((acct_resp, url))),
None => Err(
AcmeError::Account("no Location header in account lookup response".into()).into(),
),
}
}
pub async fn new_order(
&self,
account_key: &PrivateKey,
account_url: &str,
domains: &[String],
) -> Result<(AcmeOrder, String)> {
debug!(?domains, "creating new ACME order");
#[derive(Serialize)]
struct NewOrderRequest {
identifiers: Vec<AcmeIdentifier>,
}
let identifiers: Vec<AcmeIdentifier> = domains
.iter()
.map(|d| AcmeIdentifier {
id_type: "dns".to_owned(),
value: d.clone(),
})
.collect();
let req = NewOrderRequest { identifiers };
let payload = serde_json::to_string(&req)
.map_err(|e| AcmeError::Order(format!("failed to serialize order request: {e}")))?;
let (order, location): (AcmeOrder, _) = self
.acme_post_json(
account_key,
Some(account_url),
&self.directory.new_order,
Some(&payload),
)
.await?;
let order_url = location.ok_or_else(|| {
AcmeError::Order("no Location header in order creation response".into())
})?;
debug!(
status = %order.status,
order_url = %order_url,
"ACME order created"
);
Ok((order, order_url))
}
pub async fn get_authorization(
&self,
account_key: &PrivateKey,
account_url: &str,
authz_url: &str,
) -> Result<AcmeAuthorization> {
debug!(authz_url, "fetching authorization");
let (authz, _): (AcmeAuthorization, _) = self
.acme_post_json(account_key, Some(account_url), authz_url, None)
.await?;
debug!(
status = %authz.status,
identifier = %authz.identifier.value,
"authorization fetched"
);
Ok(authz)
}
pub async fn accept_challenge(
&self,
account_key: &PrivateKey,
account_url: &str,
challenge_url: &str,
) -> Result<AcmeChallenge> {
debug!(challenge_url, "accepting challenge");
let payload = "{}";
let (challenge, _): (AcmeChallenge, _) = self
.acme_post_json(account_key, Some(account_url), challenge_url, Some(payload))
.await?;
debug!(
status = %challenge.status,
challenge_type = %challenge.challenge_type,
"challenge accepted"
);
Ok(challenge)
}
pub async fn poll_authorization(
&self,
account_key: &PrivateKey,
account_url: &str,
authz_url: &str,
timeout: Duration,
) -> Result<AcmeAuthorization> {
debug!(authz_url, ?timeout, "polling authorization");
let deadline = tokio::time::Instant::now() + timeout;
let mut interval = Duration::from_secs(2);
let max_interval = Duration::from_secs(30);
loop {
let authz = self
.get_authorization(account_key, account_url, authz_url)
.await?;
match authz.status.as_str() {
"valid" => {
debug!(authz_url, "authorization is valid");
return Ok(authz);
}
"invalid" => {
let detail = authz
.challenges
.iter()
.find_map(|c| c.error.as_ref())
.map(|p| format!("{p}"))
.unwrap_or_else(|| "unknown".into());
return Err(AcmeError::Authorization(format!(
"authorization for {} failed: {detail}",
authz.identifier.value
))
.into());
}
"deactivated" | "expired" | "revoked" => {
return Err(AcmeError::Authorization(format!(
"authorization for {} has status: {}",
authz.identifier.value, authz.status
))
.into());
}
_ => {
}
}
if tokio::time::Instant::now() + interval > deadline {
return Err(crate::error::Error::Timeout(format!(
"authorization polling timed out after {timeout:?} (last status: {})",
authz.status
)));
}
tokio::time::sleep(interval).await;
interval = std::cmp::min(interval * 2, max_interval);
}
}
pub async fn finalize_order(
&self,
account_key: &PrivateKey,
account_url: &str,
finalize_url: &str,
csr_der: &[u8],
) -> Result<AcmeOrder> {
debug!(finalize_url, "finalizing order");
#[derive(Serialize)]
struct FinalizeRequest {
csr: String,
}
let req = FinalizeRequest {
csr: URL_SAFE_NO_PAD.encode(csr_der),
};
let payload = serde_json::to_string(&req)
.map_err(|e| AcmeError::Order(format!("failed to serialize finalize request: {e}")))?;
let (order, _): (AcmeOrder, _) = self
.acme_post_json(account_key, Some(account_url), finalize_url, Some(&payload))
.await?;
debug!(status = %order.status, "order finalized");
Ok(order)
}
pub async fn poll_order(
&self,
account_key: &PrivateKey,
account_url: &str,
order_url: &str,
timeout: Duration,
) -> Result<AcmeOrder> {
debug!(order_url, ?timeout, "polling order");
let deadline = tokio::time::Instant::now() + timeout;
let mut interval = Duration::from_secs(2);
let max_interval = Duration::from_secs(30);
loop {
let (order, _): (AcmeOrder, _) = self
.acme_post_json(account_key, Some(account_url), order_url, None)
.await?;
match order.status.as_str() {
"valid" => {
debug!(order_url, "order is valid");
return Ok(order);
}
"invalid" => {
let detail = order
.error
.as_ref()
.map(|p| format!("{p}"))
.unwrap_or_else(|| "unknown".into());
return Err(AcmeError::Order(format!("order failed: {detail}")).into());
}
_ => {
}
}
if tokio::time::Instant::now() + interval > deadline {
return Err(crate::error::Error::Timeout(format!(
"order polling timed out after {timeout:?} (last status: {})",
order.status
)));
}
tokio::time::sleep(interval).await;
interval = std::cmp::min(interval * 2, max_interval);
}
}
pub async fn download_certificate(
&self,
account_key: &PrivateKey,
account_url: &str,
cert_url: &str,
) -> Result<String> {
debug!(cert_url, "downloading certificate");
let resp = self
.acme_post(account_key, Some(account_url), cert_url, None)
.await?;
if !resp.status().is_success() {
return Err(AcmeError::Certificate(format!(
"certificate download returned HTTP {}",
resp.status()
))
.into());
}
let body = resp
.text()
.await
.map_err(|e| AcmeError::Certificate(format!("failed to read certificate body: {e}")))?;
debug!(cert_url, len = body.len(), "certificate downloaded");
Ok(body)
}
pub async fn revoke_certificate(
&self,
account_key: &PrivateKey,
account_url: &str,
cert_der: &[u8],
reason: Option<u8>,
) -> Result<()> {
debug!("revoking certificate");
#[derive(Serialize)]
struct RevokeRequest {
certificate: String,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<u8>,
}
let req = RevokeRequest {
certificate: URL_SAFE_NO_PAD.encode(cert_der),
reason,
};
let payload = serde_json::to_string(&req).map_err(|e| {
AcmeError::Certificate(format!("failed to serialize revoke request: {e}"))
})?;
let resp = self
.acme_post(
account_key,
Some(account_url),
&self.directory.revoke_cert,
Some(&payload),
)
.await?;
let status = resp.status();
if !status.is_success() {
let body = resp.bytes().await.unwrap_or_default();
let detail = match serde_json::from_slice::<AcmeProblem>(&body) {
Ok(p) => format!("{p}"),
Err(_) => String::from_utf8_lossy(&body).into_owned(),
};
return Err(AcmeError::Certificate(format!(
"certificate revocation failed (HTTP {status}): {detail}",
))
.into());
}
debug!("certificate revoked");
Ok(())
}
pub async fn get_renewal_info(
&self,
account_key: &PrivateKey,
account_url: &str,
cert_id: &str,
) -> Result<RenewalInfo> {
let renewal_info_base = match &self.directory.renewal_info {
Some(url) => url.clone(),
None => {
debug!("ACME directory does not advertise renewalInfo endpoint");
return Ok(RenewalInfo::default());
}
};
let url = format!("{}/{}", renewal_info_base.trim_end_matches('/'), cert_id);
debug!(url = %url, "fetching ACME Renewal Information (ARI)");
let resp = self
.acme_post(account_key, Some(account_url), &url, None)
.await?;
let status = resp.status();
if !status.is_success() {
let body = resp.bytes().await.unwrap_or_default();
let detail = String::from_utf8_lossy(&body);
warn!(
status = %status,
detail = %detail,
"ARI request returned non-success status; returning default"
);
return Ok(RenewalInfo::default());
}
let body = resp.bytes().await.map_err(|e| {
AcmeError::Certificate(format!("failed to read ARI response body: {e}"))
})?;
let info: RenewalInfo = serde_json::from_slice(&body).map_err(|e| {
AcmeError::Certificate(format!("failed to parse ARI response JSON: {e}"))
})?;
debug!(?info, "ACME Renewal Information fetched");
Ok(info)
}
}
pub fn ari_cert_id(cert_der: &[u8]) -> Result<String> {
use x509_parser::extensions::ParsedExtension;
use x509_parser::prelude::*;
let (_, cert) = X509Certificate::from_der(cert_der).map_err(|e| {
AcmeError::Certificate(format!("failed to parse certificate for ARI ID: {e}"))
})?;
let mut aki_bytes: Option<Vec<u8>> = None;
for ext in cert.extensions() {
if let ParsedExtension::AuthorityKeyIdentifier(aki) = ext.parsed_extension()
&& let Some(key_id) = &aki.key_identifier
{
aki_bytes = Some(key_id.0.to_vec());
break;
}
}
let aki_bytes = aki_bytes.ok_or_else(|| {
AcmeError::Certificate(
"certificate has no Authority Key Identifier extension with keyIdentifier".into(),
)
})?;
let serial_bytes = cert.raw_serial();
let serial_bytes = strip_leading_zeros(serial_bytes);
let aki_b64 = URL_SAFE_NO_PAD.encode(&aki_bytes);
let serial_b64 = URL_SAFE_NO_PAD.encode(serial_bytes);
Ok(format!("{aki_b64}.{serial_b64}"))
}
fn strip_leading_zeros(bytes: &[u8]) -> &[u8] {
let mut b = bytes;
while b.len() > 1 && b[0] == 0 {
b = &b[1..];
}
b
}
fn build_eab_jws(
account_key: &PrivateKey,
eab: &ExternalAccountBinding,
new_account_url: &str,
) -> Result<serde_json::Value> {
use crypto_provider::hmac;
let jwk = jwk_from_key(account_key)?;
let jwk_json = serde_json::to_vec(&jwk)
.map_err(|e| AcmeError::Account(format!("failed to serialize JWK for EAB: {e}")))?;
#[derive(Serialize)]
struct EabProtected<'a> {
alg: &'static str,
kid: &'a str,
url: &'a str,
}
let protected = EabProtected {
alg: "HS256",
kid: &eab.kid,
url: new_account_url,
};
let protected_json = serde_json::to_vec(&protected)
.map_err(|e| AcmeError::Account(format!("failed to serialize EAB protected: {e}")))?;
let protected_b64 = URL_SAFE_NO_PAD.encode(&protected_json);
let payload_b64 = URL_SAFE_NO_PAD.encode(&jwk_json);
let signing_input = format!("{protected_b64}.{payload_b64}");
let hmac_key = hmac::Key::new(hmac::HMAC_SHA256, &eab.hmac_key);
let signature = hmac::sign(&hmac_key, signing_input.as_bytes());
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.as_ref());
Ok(serde_json::json!({
"protected": protected_b64,
"payload": payload_b64,
"signature": signature_b64,
}))
}
fn extract_nonce(resp: &reqwest::Response) -> Option<String> {
resp.headers()
.get("replay-nonce")
.and_then(|v: &HeaderValue| v.to_str().ok())
.map(|s| s.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{KeyType, generate_private_key};
#[test]
fn test_jwk_from_key() {
let key = generate_private_key(KeyType::EcdsaP256).unwrap();
let jwk = jwk_from_key(&key).unwrap();
assert_eq!(jwk.alg, "ES256");
assert_eq!(jwk.crv, "P-256");
assert_eq!(jwk.kty, "EC");
assert_eq!(jwk.u, "sig");
assert!(!jwk.x.is_empty());
assert!(!jwk.y.is_empty());
}
#[test]
fn test_jwk_thumbprint_deterministic() {
let key = generate_private_key(KeyType::EcdsaP256).unwrap();
let jwk = jwk_from_key(&key).unwrap();
let t1 = jwk_thumbprint(&jwk);
let t2 = jwk_thumbprint(&jwk);
assert_eq!(t1, t2);
assert!(!t1.is_empty());
assert!(!t1.contains('+'));
assert!(!t1.contains('/'));
}
#[test]
fn test_key_authorization_format() {
let key = generate_private_key(KeyType::EcdsaP256).unwrap();
let token = "test_token_12345";
let ka = key_authorization(token, &key).unwrap();
assert!(ka.starts_with(token));
assert!(ka.contains('.'));
let parts: Vec<&str> = ka.splitn(2, '.').collect();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0], token);
assert!(!parts[1].is_empty());
}
#[test]
fn test_key_authorization_deterministic() {
let key = generate_private_key(KeyType::EcdsaP256).unwrap();
let ka1 = key_authorization("tok", &key).unwrap();
let ka2 = key_authorization("tok", &key).unwrap();
assert_eq!(ka1, ka2);
}
#[test]
fn test_key_authorization_sha256() {
let key = generate_private_key(KeyType::EcdsaP256).unwrap();
let hash = key_authorization_sha256("tok", &key).unwrap();
assert_eq!(hash.len(), 32); }
#[test]
fn test_sign_with_key() {
let key = generate_private_key(KeyType::EcdsaP256).unwrap();
let data = b"hello world";
let sig = sign_with_key(&key, data).unwrap();
assert_eq!(sig.len(), 64);
}
#[test]
fn test_build_jws_body_with_jwk() {
let key = generate_private_key(KeyType::EcdsaP256).unwrap();
let body = build_jws_body(
&key,
None,
"nonce123",
"https://example.com/new-acct",
Some("{}"),
)
.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(parsed.get("protected").is_some());
assert!(parsed.get("payload").is_some());
assert!(parsed.get("signature").is_some());
}
#[test]
fn test_build_jws_body_with_kid() {
let key = generate_private_key(KeyType::EcdsaP256).unwrap();
let body = build_jws_body(
&key,
Some("https://example.com/acme/acct/1"),
"nonce456",
"https://example.com/new-order",
Some(r#"{"identifiers":[]}"#),
)
.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(parsed.get("protected").is_some());
}
#[test]
fn test_build_jws_body_post_as_get() {
let key = generate_private_key(KeyType::EcdsaP256).unwrap();
let body = build_jws_body(
&key,
Some("https://example.com/acme/acct/1"),
"nonce789",
"https://example.com/some-resource",
None,
)
.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
let payload = parsed.get("payload").unwrap().as_str().unwrap();
assert!(payload.is_empty());
}
#[test]
fn test_acme_problem_display() {
let problem = AcmeProblem {
problem_type: "urn:ietf:params:acme:error:malformed".into(),
detail: "something went wrong".into(),
status: Some(400),
subproblems: None,
};
let display = format!("{problem}");
assert!(display.contains("something went wrong"));
assert!(display.contains("malformed"));
}
#[test]
fn test_acme_problem_display_no_type() {
let problem = AcmeProblem {
problem_type: String::new(),
detail: "just a message".into(),
status: None,
subproblems: None,
};
let display = format!("{problem}");
assert_eq!(display, "just a message");
}
#[test]
fn test_directory_constants() {
assert!(LETS_ENCRYPT_PRODUCTION.starts_with("https://"));
assert!(LETS_ENCRYPT_STAGING.starts_with("https://"));
assert!(ZEROSSL_PRODUCTION.starts_with("https://"));
assert!(GOOGLE_TRUST_STAGING.starts_with("https://"));
assert!(GOOGLE_TRUST_PRODUCTION.starts_with("https://"));
}
}