Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::jose::{Body, eab_sign, sign};
|
||||
use crate::network::acme::http::{get_header, https};
|
||||
use crate::network::acme::{AcmeError, AcmeResult, Directory};
|
||||
use aws_lc_rs::rand::SystemRandom;
|
||||
use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, EcdsaSigningAlgorithm};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::{self, URL_SAFE_NO_PAD};
|
||||
use registry::schema::structs::AcmeProvider;
|
||||
use reqwest::Method;
|
||||
use utils::sanitize_email;
|
||||
|
||||
static ALG: &EcdsaSigningAlgorithm = &ECDSA_P256_SHA256_FIXED_SIGNING;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EabSettings {
|
||||
pub kid: String,
|
||||
pub hmac_key: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct NewAccountPayload<'x> {
|
||||
#[serde(rename = "termsOfServiceAgreed")]
|
||||
tos_agreed: bool,
|
||||
contact: &'x [String],
|
||||
#[serde(rename = "externalAccountBinding")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
eab: Option<Body>,
|
||||
}
|
||||
|
||||
pub async fn acme_create_account(
|
||||
provider: &mut AcmeProvider,
|
||||
eab: Option<EabSettings>,
|
||||
) -> AcmeResult<()> {
|
||||
if provider.contact.is_empty() {
|
||||
return Err(AcmeError::Invalid(
|
||||
"At least one contact email is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
for contact in provider.contact.iter_mut() {
|
||||
let email = sanitize_email(contact.trim().strip_prefix("mailto:").unwrap_or(contact))
|
||||
.ok_or_else(|| AcmeError::Invalid(format!("Invalid contact email: {}", contact)))?;
|
||||
*contact = format!("mailto:{}", email);
|
||||
}
|
||||
|
||||
let directory = Directory::discover(&provider.directory, provider.max_retries as u32).await?;
|
||||
let account_key = EcdsaKeyPair::generate_pkcs8(ALG, &SystemRandom::new()).unwrap();
|
||||
let key_pair = EcdsaKeyPair::from_pkcs8(ALG, account_key.as_ref())
|
||||
.map_err(|err| AcmeError::Crypto(format!("Failed to create ECDSA key pair: {}", err)))?;
|
||||
let eab = if let Some(eab) = &eab {
|
||||
eab_sign(&key_pair, &eab.kid, &eab.hmac_key, &directory.new_account)?.into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let payload = serde_json::to_string(&NewAccountPayload {
|
||||
tos_agreed: true,
|
||||
contact: provider.contact.as_slice(),
|
||||
eab,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let body = sign(
|
||||
&key_pair,
|
||||
None,
|
||||
directory.nonce(provider.max_retries as u32).await?,
|
||||
&directory.new_account,
|
||||
&payload,
|
||||
)?;
|
||||
|
||||
provider.account_uri = get_header(
|
||||
&https(
|
||||
&directory.new_account,
|
||||
Method::POST,
|
||||
Some(body),
|
||||
provider.max_retries as u32,
|
||||
)
|
||||
.await?,
|
||||
"Location",
|
||||
)?;
|
||||
provider.account_key = URL_SAFE_NO_PAD.encode(account_key.as_ref());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl EabSettings {
|
||||
pub fn new(kid: impl Into<String>, hmac_key: impl AsRef<[u8]>) -> AcmeResult<Self> {
|
||||
let key = general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(hmac_key.as_ref())
|
||||
.map_err(|err| AcmeError::Invalid(format!("Failed to decode EAB HMAC key: {}", err)))?;
|
||||
Ok(Self {
|
||||
kid: kid.into(),
|
||||
hmac_key: key,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
|
||||
|
||||
use super::jose::{
|
||||
key_authorization, key_authorization_sha256, key_authorization_sha256_base64, sign,
|
||||
};
|
||||
use crate::network::acme::http::{get_header, https, parse_alternate_links, parse_retry_after};
|
||||
use crate::network::acme::{
|
||||
AcmeError, AcmeResult, Auth, AuthStatus, Challenge, ChallengeType, Directory, Identifier,
|
||||
Order, SerializedCert,
|
||||
};
|
||||
use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, EcdsaSigningAlgorithm};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use rcgen::{CustomExtension, KeyPair, PKCS_ECDSA_P256_SHA256};
|
||||
use registry::schema::structs::AcmeProvider;
|
||||
use reqwest::Method;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use store::Serialize;
|
||||
use store::write::Archiver;
|
||||
|
||||
pub const ACME_TLS_ALPN_NAME: &[u8] = b"acme-tls/1";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AcmeRequestBuilder {
|
||||
pub key_pair: EcdsaKeyPair,
|
||||
pub directory: Directory,
|
||||
pub kid: String,
|
||||
pub challenge: ChallengeType,
|
||||
pub max_retries: u32,
|
||||
pub preferred_chain: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AcmeResponse<L, B> {
|
||||
pub location: L,
|
||||
pub body: B,
|
||||
pub retry_after: Option<Duration>,
|
||||
pub alternates: Vec<String>,
|
||||
}
|
||||
|
||||
static ALG: &EcdsaSigningAlgorithm = &ECDSA_P256_SHA256_FIXED_SIGNING;
|
||||
|
||||
impl AcmeRequestBuilder {
|
||||
pub async fn new(provider: AcmeProvider) -> AcmeResult<Self> {
|
||||
let directory =
|
||||
Directory::discover(&provider.directory, provider.max_retries as u32).await?;
|
||||
let key_pair = EcdsaKeyPair::from_pkcs8(
|
||||
ALG,
|
||||
&URL_SAFE_NO_PAD
|
||||
.decode(&provider.account_key)
|
||||
.map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to decode account key: {}", err))
|
||||
})?,
|
||||
)
|
||||
.map_err(|err| AcmeError::Crypto(format!("Failed to create ECDSA key pair: {}", err)))?;
|
||||
|
||||
Ok(Self {
|
||||
key_pair,
|
||||
directory,
|
||||
kid: provider.account_uri,
|
||||
challenge: provider.challenge_type.into(),
|
||||
max_retries: provider.max_retries as u32,
|
||||
preferred_chain: provider.preferred_chain,
|
||||
})
|
||||
}
|
||||
|
||||
async fn request(
|
||||
&self,
|
||||
url: impl AsRef<str>,
|
||||
payload: &str,
|
||||
) -> AcmeResult<AcmeResponse<Option<String>, String>> {
|
||||
let body = sign(
|
||||
&self.key_pair,
|
||||
Some(&self.kid),
|
||||
self.directory.nonce(self.max_retries).await?,
|
||||
url.as_ref(),
|
||||
payload,
|
||||
)?;
|
||||
let response = https(url.as_ref(), Method::POST, Some(body), self.max_retries).await?;
|
||||
|
||||
Ok(AcmeResponse {
|
||||
location: get_header(&response, "Location").ok(),
|
||||
retry_after: parse_retry_after(&response),
|
||||
alternates: parse_alternate_links(&response),
|
||||
body: response.text().await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn new_order(&self, domains: Vec<String>) -> AcmeResult<AcmeResponse<String, Order>> {
|
||||
let domains: Vec<Identifier> = domains.into_iter().map(Identifier::Dns).collect();
|
||||
let payload = json!({
|
||||
"identifiers": domains,
|
||||
})
|
||||
.to_string();
|
||||
let response = self.request(&self.directory.new_order, &payload).await?;
|
||||
Ok(AcmeResponse {
|
||||
location: response.location.ok_or(AcmeError::Invalid(format!(
|
||||
"Missing Location header in new order response from {}",
|
||||
self.directory.new_order
|
||||
)))?,
|
||||
body: serde_json::from_str(&response.body).map_err(AcmeError::Json)?,
|
||||
retry_after: response.retry_after,
|
||||
alternates: response.alternates,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn auth(
|
||||
&self,
|
||||
url: impl AsRef<str>,
|
||||
) -> AcmeResult<AcmeResponse<Option<String>, Auth>> {
|
||||
AcmeResponse::parse(self.request(url, "").await?)
|
||||
}
|
||||
|
||||
pub async fn challenge(&self, url: impl AsRef<str>) -> AcmeResult<()> {
|
||||
self.request(&url, "{}").await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn order(
|
||||
&self,
|
||||
url: impl AsRef<str>,
|
||||
) -> AcmeResult<AcmeResponse<Option<String>, Order>> {
|
||||
AcmeResponse::parse(self.request(&url, "").await?)
|
||||
}
|
||||
|
||||
pub async fn finalize(
|
||||
&self,
|
||||
url: impl AsRef<str>,
|
||||
csr: Vec<u8>,
|
||||
) -> AcmeResult<AcmeResponse<Option<String>, Order>> {
|
||||
let payload = format!("{{\"csr\":\"{}\"}}", URL_SAFE_NO_PAD.encode(csr));
|
||||
AcmeResponse::parse(self.request(&url, &payload).await?)
|
||||
}
|
||||
|
||||
pub async fn certificate(
|
||||
&self,
|
||||
url: impl AsRef<str>,
|
||||
) -> AcmeResult<AcmeResponse<Option<String>, String>> {
|
||||
self.request(&url, "").await
|
||||
}
|
||||
|
||||
pub fn http_proof(&self, challenge: &Challenge) -> AcmeResult<Vec<u8>> {
|
||||
let challenge_token = challenge.token.as_deref().ok_or_else(|| {
|
||||
AcmeError::Invalid("Missing http-01 challenge token in response".to_string())
|
||||
})?;
|
||||
key_authorization(&self.key_pair, challenge_token).map(|key| key.into_bytes())
|
||||
}
|
||||
|
||||
pub fn dns_proof(&self, challenge: &Challenge) -> AcmeResult<String> {
|
||||
let challenge_token = challenge.token.as_deref().ok_or_else(|| {
|
||||
AcmeError::Invalid("Missing dns-01 challenge token in response".to_string())
|
||||
})?;
|
||||
key_authorization_sha256_base64(&self.key_pair, challenge_token)
|
||||
}
|
||||
|
||||
pub fn tls_alpn_key(&self, challenge: &Challenge, domain: String) -> AcmeResult<Vec<u8>> {
|
||||
let challenge_token = challenge.token.as_deref().ok_or_else(|| {
|
||||
AcmeError::Invalid("Missing tls-alpn-01 challenge token in response".to_string())
|
||||
})?;
|
||||
let mut params = rcgen::CertificateParams::new(vec![domain]).map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to create certificate params: {}", err))
|
||||
})?;
|
||||
let key_auth = key_authorization_sha256(&self.key_pair, challenge_token)?;
|
||||
params.custom_extensions = vec![CustomExtension::new_acme_identifier(key_auth.as_ref())];
|
||||
let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)
|
||||
.map_err(|err| AcmeError::Crypto(format!("Failed to generate key pair: {}", err)))?;
|
||||
let cert = params.self_signed(&key_pair).map_err(|err| {
|
||||
AcmeError::Crypto(format!(
|
||||
"Failed to generate TLS-ALPN-01 certificate: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
|
||||
Archiver::new(SerializedCert {
|
||||
certificate: cert.der().to_vec(),
|
||||
private_key: key_pair.serialize_der(),
|
||||
})
|
||||
.untrusted()
|
||||
.serialize()
|
||||
.map_err(|_| AcmeError::Crypto("Failed to serialize certificate".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Directory {
|
||||
pub async fn discover(url: impl AsRef<str>, max_retries: u32) -> AcmeResult<Self> {
|
||||
serde_json::from_str(
|
||||
&https(url, Method::GET, None, max_retries)
|
||||
.await?
|
||||
.text()
|
||||
.await?,
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn nonce(&self, max_retries: u32) -> AcmeResult<String> {
|
||||
get_header(
|
||||
&https(&self.new_nonce.as_str(), Method::HEAD, None, max_retries).await?,
|
||||
"replay-nonce",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<L, T: DeserializeOwned> AcmeResponse<L, T> {
|
||||
pub fn parse(input: AcmeResponse<L, String>) -> AcmeResult<AcmeResponse<L, T>> {
|
||||
serde_json::from_str(&input.body)
|
||||
.map_err(|err| {
|
||||
AcmeError::Invalid(format!(
|
||||
"ACME response parsing error: {}, body: {}",
|
||||
err, input.body
|
||||
))
|
||||
})
|
||||
.map(|body| AcmeResponse {
|
||||
location: input.location,
|
||||
body,
|
||||
retry_after: input.retry_after,
|
||||
alternates: input.alternates,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<L, T> AcmeResponse<L, T> {
|
||||
pub fn assert_reasonable_retry_after(self, max_retries: u32) -> AcmeResult<Self> {
|
||||
if let Some(retry_after) = self.retry_after
|
||||
&& retry_after > Duration::from_secs(10 * 60)
|
||||
{
|
||||
trc::event!(
|
||||
Acme(trc::AcmeEvent::RenewBackoff),
|
||||
Elapsed = retry_after,
|
||||
Reason = "ACME server requested an excessively long Retry-After",
|
||||
);
|
||||
|
||||
return Err(AcmeError::Backoff {
|
||||
max_retries,
|
||||
wait: retry_after.into(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl ChallengeType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Http01 => "http-01",
|
||||
Self::Dns01 => "dns-01",
|
||||
Self::TlsAlpn01 => "tls-alpn-01",
|
||||
Self::DnsPersist01 => "dns-persist-01",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Valid => "valid",
|
||||
Self::Invalid => "invalid",
|
||||
Self::Revoked => "revoked",
|
||||
Self::Expired => "expired",
|
||||
Self::Deactivated => "deactivated",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::network::acme::{AcmeError, AcmeResult};
|
||||
use chrono::{DateTime, Utc};
|
||||
use hyper::{
|
||||
Method, StatusCode,
|
||||
header::{CONTENT_TYPE, USER_AGENT},
|
||||
};
|
||||
use reqwest::Response;
|
||||
use std::time::Duration;
|
||||
|
||||
#[allow(unused_mut)]
|
||||
pub(crate) async fn https(
|
||||
url: impl AsRef<str>,
|
||||
method: Method,
|
||||
body: Option<String>,
|
||||
max_retries: u32,
|
||||
) -> AcmeResult<Response> {
|
||||
let url = url.as_ref();
|
||||
|
||||
#[allow(unused_mut)]
|
||||
#[allow(unused_assignments)]
|
||||
let mut allow_invalid_certs = false;
|
||||
|
||||
#[cfg(any(feature = "dev_mode", feature = "test_mode"))]
|
||||
{
|
||||
allow_invalid_certs =
|
||||
url.starts_with("https://localhost") || url.starts_with("https://127.0.0.1");
|
||||
}
|
||||
|
||||
let mut request = utils::http::http1_client_builder(allow_invalid_certs)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()?
|
||||
.request(method, url)
|
||||
.header(USER_AGENT, crate::USER_AGENT);
|
||||
|
||||
if let Some(body) = body {
|
||||
request = request
|
||||
.header(CONTENT_TYPE, "application/jose+json")
|
||||
.body(body);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
if response.status().is_success() {
|
||||
Ok(response)
|
||||
} else if matches!(
|
||||
response.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE
|
||||
) {
|
||||
let wait = parse_retry_after(&response);
|
||||
|
||||
trc::event!(
|
||||
Acme(trc::AcmeEvent::RenewBackoff),
|
||||
Url = url.to_string(),
|
||||
Code = response.status().as_u16(),
|
||||
Elapsed = wait.unwrap_or_default(),
|
||||
);
|
||||
|
||||
Err(AcmeError::Backoff { wait, max_retries })
|
||||
} else {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
Err(AcmeError::HttpStatus(format!(
|
||||
"Unexpected status {}: {}",
|
||||
status, text
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_header(response: &Response, header: &'static str) -> AcmeResult<String> {
|
||||
match response.headers().get_all(header).iter().next_back() {
|
||||
Some(value) => Ok(value
|
||||
.to_str()
|
||||
.map_err(|err| {
|
||||
AcmeError::Invalid(format!("Failed to read header {}: {}", header, err))
|
||||
})?
|
||||
.to_string()),
|
||||
None => Err(AcmeError::Invalid(format!("Missing header: {}", header))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_alternate_links(response: &Response) -> Vec<String> {
|
||||
alternate_links(
|
||||
response
|
||||
.headers()
|
||||
.get_all("Link")
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok()),
|
||||
)
|
||||
}
|
||||
|
||||
fn alternate_links<'a>(values: impl Iterator<Item = &'a str>) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
for value in values {
|
||||
for link in value.split(',') {
|
||||
let mut url = None;
|
||||
let mut is_alternate = false;
|
||||
for (index, part) in link.split(';').enumerate() {
|
||||
let part = part.trim();
|
||||
if index == 0 {
|
||||
url = part
|
||||
.strip_prefix('<')
|
||||
.and_then(|part| part.strip_suffix('>'));
|
||||
} else if let Some(rel) = part.strip_prefix("rel=") {
|
||||
is_alternate = rel.trim_matches('"') == "alternate";
|
||||
}
|
||||
}
|
||||
if is_alternate && let Some(url) = url {
|
||||
urls.push(url.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
urls
|
||||
}
|
||||
|
||||
pub(crate) fn parse_retry_after(response: &Response) -> Option<Duration> {
|
||||
let value = response.headers().get("Retry-After")?.to_str().ok()?;
|
||||
if let Ok(secs) = value.parse::<u64>() {
|
||||
Some(Duration::from_secs(secs + 1))
|
||||
} else if let Ok(dt) = DateTime::parse_from_rfc2822(value) {
|
||||
Utc::now()
|
||||
.signed_duration_since(dt.with_timezone(&Utc))
|
||||
.to_std()
|
||||
.map(|dur| dur + Duration::from_secs(1))
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::alternate_links;
|
||||
|
||||
#[test]
|
||||
fn parses_single_alternate_link() {
|
||||
let links =
|
||||
alternate_links([r#"<https://acme.example/cert/1/1>;rel="alternate""#].into_iter());
|
||||
assert_eq!(links, vec!["https://acme.example/cert/1/1".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_alternates_in_one_header() {
|
||||
let links = alternate_links(
|
||||
[r#"<https://acme.example/cert/1/1>;rel="alternate", <https://acme.example/cert/1/2>;rel="alternate""#]
|
||||
.into_iter(),
|
||||
);
|
||||
assert_eq!(
|
||||
links,
|
||||
vec![
|
||||
"https://acme.example/cert/1/1".to_string(),
|
||||
"https://acme.example/cert/1/2".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_alternates_across_multiple_headers() {
|
||||
let links = alternate_links(
|
||||
[
|
||||
r#"<https://acme.example/cert/1/1>;rel="alternate""#,
|
||||
r#"<https://acme.example/cert/1/2>;rel="alternate""#,
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
assert_eq!(
|
||||
links,
|
||||
vec![
|
||||
"https://acme.example/cert/1/1".to_string(),
|
||||
"https://acme.example/cert/1/2".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_alternate_relations() {
|
||||
let links = alternate_links(
|
||||
[r#"<https://acme.example/index>;rel="index", <https://acme.example/cert/1/1>;rel="alternate""#]
|
||||
.into_iter(),
|
||||
);
|
||||
assert_eq!(links, vec!["https://acme.example/cert/1/1".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_unquoted_rel_and_extra_whitespace() {
|
||||
let links =
|
||||
alternate_links([r#" <https://acme.example/cert/1/1> ; rel=alternate "#].into_iter());
|
||||
assert_eq!(links, vec!["https://acme.example/cert/1/1".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_empty_when_no_alternates() {
|
||||
let links = alternate_links([r#"<https://acme.example/dir>;rel="index""#].into_iter());
|
||||
assert!(links.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
|
||||
|
||||
use crate::network::acme::{AcmeError, AcmeResult};
|
||||
use aws_lc_rs::digest::{Digest, SHA256, digest};
|
||||
use aws_lc_rs::hmac;
|
||||
use aws_lc_rs::rand::SystemRandom;
|
||||
use aws_lc_rs::signature::{EcdsaKeyPair, KeyPair};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use serde::Serialize;
|
||||
|
||||
pub(crate) fn sign(
|
||||
key: &EcdsaKeyPair,
|
||||
kid: Option<&str>,
|
||||
nonce: String,
|
||||
url: &str,
|
||||
payload: &str,
|
||||
) -> AcmeResult<String> {
|
||||
let jwk = match kid {
|
||||
None => Some(Jwk::new(key)),
|
||||
Some(_) => None,
|
||||
};
|
||||
let protected = Protected::encode("ES256", jwk, kid, nonce.into(), url)?;
|
||||
let payload = URL_SAFE_NO_PAD.encode(payload);
|
||||
let combined = format!("{}.{}", protected, payload);
|
||||
let signature = key
|
||||
.sign(&SystemRandom::new(), combined.as_bytes())
|
||||
.map_err(|err| AcmeError::Crypto(format!("Failed to sign payload: {}", err)))?;
|
||||
|
||||
serde_json::to_string(&Body {
|
||||
protected,
|
||||
payload,
|
||||
signature: URL_SAFE_NO_PAD.encode(signature.as_ref()),
|
||||
})
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(crate) fn eab_sign(
|
||||
key: &EcdsaKeyPair,
|
||||
kid: &str,
|
||||
hmac_key: &[u8],
|
||||
url: &str,
|
||||
) -> AcmeResult<Body> {
|
||||
let protected = Protected::encode("HS256", None, kid.into(), None, url)?;
|
||||
let payload = Jwk::new(key).base64()?;
|
||||
let combined = format!("{}.{}", protected, payload);
|
||||
|
||||
let key = hmac::Key::new(hmac::HMAC_SHA256, hmac_key);
|
||||
let tag = hmac::sign(&key, combined.as_bytes());
|
||||
let signature = URL_SAFE_NO_PAD.encode(tag.as_ref());
|
||||
|
||||
Ok(Body {
|
||||
protected,
|
||||
payload,
|
||||
signature,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn key_authorization(key: &EcdsaKeyPair, token: &str) -> AcmeResult<String> {
|
||||
Ok(format!(
|
||||
"{}.{}",
|
||||
token,
|
||||
Jwk::new(key).thumb_sha256_base64()?
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn key_authorization_sha256(key: &EcdsaKeyPair, token: &str) -> AcmeResult<Digest> {
|
||||
key_authorization(key, token).map(|s| digest(&SHA256, s.as_bytes()))
|
||||
}
|
||||
|
||||
pub(crate) fn key_authorization_sha256_base64(
|
||||
key: &EcdsaKeyPair,
|
||||
token: &str,
|
||||
) -> AcmeResult<String> {
|
||||
key_authorization_sha256(key, token).map(|s| URL_SAFE_NO_PAD.encode(s.as_ref()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct Body {
|
||||
protected: String,
|
||||
payload: String,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Protected<'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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
nonce: Option<String>,
|
||||
url: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> Protected<'a> {
|
||||
fn encode(
|
||||
alg: &'static str,
|
||||
jwk: Option<Jwk>,
|
||||
kid: Option<&'a str>,
|
||||
nonce: Option<String>,
|
||||
url: &'a str,
|
||||
) -> AcmeResult<String> {
|
||||
serde_json::to_vec(&Protected {
|
||||
alg,
|
||||
jwk,
|
||||
kid,
|
||||
nonce,
|
||||
url,
|
||||
})
|
||||
.map_err(Into::into)
|
||||
.map(|v| URL_SAFE_NO_PAD.encode(v.as_slice()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Jwk {
|
||||
alg: &'static str,
|
||||
crv: &'static str,
|
||||
kty: &'static str,
|
||||
#[serde(rename = "use")]
|
||||
u: &'static str,
|
||||
x: String,
|
||||
y: String,
|
||||
}
|
||||
|
||||
impl Jwk {
|
||||
pub(crate) fn new(key: &EcdsaKeyPair) -> Self {
|
||||
let (x, y) = key.public_key().as_ref()[1..].split_at(32);
|
||||
Self {
|
||||
alg: "ES256",
|
||||
crv: "P-256",
|
||||
kty: "EC",
|
||||
u: "sig",
|
||||
x: URL_SAFE_NO_PAD.encode(x),
|
||||
y: URL_SAFE_NO_PAD.encode(y),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn base64(&self) -> AcmeResult<String> {
|
||||
serde_json::to_vec(self)
|
||||
.map_err(Into::into)
|
||||
.map(|v| URL_SAFE_NO_PAD.encode(v.as_slice()))
|
||||
}
|
||||
|
||||
pub(crate) fn thumb_sha256_base64(&self) -> AcmeResult<String> {
|
||||
Ok(URL_SAFE_NO_PAD.encode(digest(
|
||||
&SHA256,
|
||||
&serde_json::to_vec(&JwkThumb {
|
||||
crv: self.crv,
|
||||
kty: self.kty,
|
||||
x: &self.x,
|
||||
y: &self.y,
|
||||
})
|
||||
.map_err(AcmeError::Json)?,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct JwkThumb<'a> {
|
||||
crv: &'a str,
|
||||
kty: &'a str,
|
||||
x: &'a str,
|
||||
y: &'a str,
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod account;
|
||||
pub mod directory;
|
||||
pub mod http;
|
||||
pub mod jose;
|
||||
pub mod order;
|
||||
pub mod renew;
|
||||
pub mod resolver;
|
||||
|
||||
use crate::network::dns::update::DnsUpdater;
|
||||
use chrono::{DateTime, Utc};
|
||||
use registry::schema::enums::AcmeChallengeType;
|
||||
use rustls::sign::CertifiedKey;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
fmt::{Debug, Display},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use store::registry::write::RegistryWriteResult;
|
||||
|
||||
pub type AcmeResult<T> = Result<T, AcmeError>;
|
||||
|
||||
pub enum AcmeError {
|
||||
Http(reqwest::Error),
|
||||
HttpStatus(String),
|
||||
Json(serde_json::Error),
|
||||
Crypto(String),
|
||||
Invalid(String),
|
||||
NotDue(String),
|
||||
Dns(String),
|
||||
AuthInvalid(String),
|
||||
OrderInvalid(String),
|
||||
ChallengeNotSupported {
|
||||
requested: ChallengeType,
|
||||
supported: Vec<Challenge>,
|
||||
},
|
||||
Internal(trc::Error),
|
||||
Registry(RegistryWriteResult),
|
||||
OrderTimeout {
|
||||
max_retries: u32,
|
||||
},
|
||||
AuthTimeout {
|
||||
max_retries: u32,
|
||||
},
|
||||
Backoff {
|
||||
max_retries: u32,
|
||||
wait: Option<Duration>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(
|
||||
rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug, Clone, serde::Serialize, Deserialize,
|
||||
)]
|
||||
pub struct SerializedCert {
|
||||
pub certificate: Vec<u8>,
|
||||
pub private_key: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct PemCert {
|
||||
pub certificate: String,
|
||||
pub private_key: String,
|
||||
}
|
||||
|
||||
pub struct ParsedCert {
|
||||
pub sans: Vec<String>,
|
||||
pub issuer: String,
|
||||
pub valid_not_before: DateTime<Utc>,
|
||||
pub valid_not_after: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Directory {
|
||||
pub new_nonce: String,
|
||||
pub new_account: String,
|
||||
pub new_order: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Eq, PartialEq, Clone, Copy)]
|
||||
pub enum ChallengeType {
|
||||
#[serde(rename = "http-01")]
|
||||
Http01,
|
||||
#[serde(rename = "dns-01")]
|
||||
Dns01,
|
||||
#[serde(rename = "dns-persist-01")]
|
||||
DnsPersist01,
|
||||
#[serde(rename = "tls-alpn-01")]
|
||||
TlsAlpn01,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Order {
|
||||
#[serde(flatten)]
|
||||
pub status: OrderStatus,
|
||||
pub authorizations: Vec<String>,
|
||||
pub finalize: String,
|
||||
pub error: Option<Problem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum OrderStatus {
|
||||
Pending,
|
||||
Ready,
|
||||
Valid { certificate: String },
|
||||
Invalid,
|
||||
Processing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Auth {
|
||||
pub status: AuthStatus,
|
||||
pub identifier: Identifier,
|
||||
pub challenges: Vec<Challenge>,
|
||||
pub wildcard: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AuthStatus {
|
||||
Pending,
|
||||
Valid,
|
||||
Invalid,
|
||||
Revoked,
|
||||
Expired,
|
||||
Deactivated,
|
||||
}
|
||||
|
||||
pub struct AcmeDnsParameters {
|
||||
pub updater: DnsUpdater,
|
||||
pub origin: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "value", rename_all = "camelCase")]
|
||||
pub enum Identifier {
|
||||
Dns(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Challenge {
|
||||
#[serde(rename = "type")]
|
||||
pub typ: ChallengeType,
|
||||
pub url: String,
|
||||
pub token: Option<String>,
|
||||
pub error: Option<Problem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Problem {
|
||||
#[serde(rename = "type")]
|
||||
pub typ: Option<String>,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
pub struct StaticResolver {
|
||||
pub key: Option<Arc<CertifiedKey>>,
|
||||
}
|
||||
|
||||
impl Debug for StaticResolver {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("StaticResolver").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for AcmeError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
AcmeError::Http(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for AcmeError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
AcmeError::Json(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<trc::Error> for AcmeError {
|
||||
fn from(err: trc::Error) -> Self {
|
||||
AcmeError::Internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AcmeChallengeType> for ChallengeType {
|
||||
fn from(value: AcmeChallengeType) -> Self {
|
||||
match value {
|
||||
AcmeChallengeType::Http01 => ChallengeType::Http01,
|
||||
AcmeChallengeType::Dns01 => ChallengeType::Dns01,
|
||||
AcmeChallengeType::TlsAlpn01 => ChallengeType::TlsAlpn01,
|
||||
AcmeChallengeType::DnsPersist01 => ChallengeType::DnsPersist01,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for AuthStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AuthStatus::Pending => write!(f, "pending"),
|
||||
AuthStatus::Valid => write!(f, "valid"),
|
||||
AuthStatus::Invalid => write!(f, "invalid"),
|
||||
AuthStatus::Revoked => write!(f, "revoked"),
|
||||
AuthStatus::Expired => write!(f, "expired"),
|
||||
AuthStatus::Deactivated => write!(f, "deactivated"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Auth {
|
||||
pub fn to_error(&self) -> String {
|
||||
let mut errors = format!("Status: {}", self.status);
|
||||
for challenge in &self.challenges {
|
||||
if let Some(error) = &challenge.error {
|
||||
errors.push_str(&format!(
|
||||
"; Challenge type: {}, error: {}",
|
||||
challenge.typ.as_str(),
|
||||
error
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
||||
impl Identifier {
|
||||
pub fn hostname(&self) -> &str {
|
||||
match self {
|
||||
Identifier::Dns(hostname) => hostname,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Problem {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(typ) = &self.typ {
|
||||
if let Some(detail) = &self.detail {
|
||||
write!(f, "{}: {}", typ, detail)
|
||||
} else {
|
||||
write!(f, "{}", typ)
|
||||
}
|
||||
} else if let Some(detail) = &self.detail {
|
||||
write!(f, "{}", detail)
|
||||
} else {
|
||||
write!(f, "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for AcmeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AcmeError::Http(err) => write!(f, "HTTP error: {}", err),
|
||||
AcmeError::HttpStatus(status) => write!(f, "HTTP error: {}", status),
|
||||
AcmeError::Json(err) => write!(f, "JSON error: {}", err),
|
||||
AcmeError::Dns(err) => write!(f, "DNS error: {}", err),
|
||||
AcmeError::Crypto(err) => write!(f, "Cryptographic error: {}", err),
|
||||
AcmeError::Invalid(err) => write!(f, "Invalid request: {}", err),
|
||||
AcmeError::NotDue(err) => write!(f, "{}", err),
|
||||
AcmeError::AuthInvalid(status) => write!(f, "Authentication failed: {:?}", status),
|
||||
AcmeError::OrderTimeout { .. } => write!(f, "Order processing timed out"),
|
||||
AcmeError::OrderInvalid(reason) => write!(f, "Order is invalid: {}", reason),
|
||||
AcmeError::AuthTimeout { .. } => write!(f, "Authentication timed out"),
|
||||
AcmeError::ChallengeNotSupported {
|
||||
requested,
|
||||
supported,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"Challenge type {:?} not supported. Supported types: {:?}",
|
||||
requested, supported
|
||||
)
|
||||
}
|
||||
AcmeError::Internal(err) => write!(f, "Internal error: {}", err),
|
||||
AcmeError::Registry(err) => write!(f, "Registry error: {:?}", err),
|
||||
AcmeError::Backoff { wait, .. } => {
|
||||
if let Some(time) = wait {
|
||||
write!(f, "Rate limited. Retry after {} seconds", time.as_secs())
|
||||
} else {
|
||||
write!(f, "Rate limited. Retry after some time")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
|
||||
|
||||
use crate::network::acme::directory::AcmeRequestBuilder;
|
||||
use crate::network::acme::{
|
||||
AcmeDnsParameters, AcmeError, AcmeResult, AuthStatus, ChallengeType, Identifier, OrderStatus,
|
||||
ParsedCert, PemCert,
|
||||
};
|
||||
use crate::{KV_ACME, Server};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use dns_update::DnsRecord;
|
||||
use futures::future::try_join_all;
|
||||
use rcgen::{CertificateParams, DistinguishedName, KeyPair, PKCS_ECDSA_P256_SHA256};
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::time::Duration;
|
||||
use store::dispatch::lookup::KeyValue;
|
||||
use trc::AcmeEvent;
|
||||
use x509_parser::parse_x509_certificate;
|
||||
use x509_parser::prelude::{GeneralName, ParsedExtension};
|
||||
|
||||
const HOSTNAMES: &[&str] = &["mta-sts", "ua-auto-config", "autoconfig", "autodiscover"];
|
||||
|
||||
impl AcmeRequestBuilder {
|
||||
pub fn build_domains(
|
||||
&self,
|
||||
server: &Server,
|
||||
domain: &str,
|
||||
hostnames: &[String],
|
||||
) -> Vec<String> {
|
||||
if hostnames.is_empty() {
|
||||
if matches!(
|
||||
self.challenge,
|
||||
ChallengeType::Dns01 | ChallengeType::DnsPersist01
|
||||
) {
|
||||
vec![format!("*.{domain}"), domain.to_string()]
|
||||
} else {
|
||||
let server_name = server.core.network.server_name.as_str();
|
||||
let domain_suffix = format!(".{domain}");
|
||||
let matches_zone = |name: &str| name == domain || name.ends_with(&domain_suffix);
|
||||
|
||||
// Add technical domains
|
||||
let mut domains = HOSTNAMES
|
||||
.iter()
|
||||
.map(|hostname| format!("{hostname}.{domain}"))
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
// Add server name if it matches the domain (including the apex itself)
|
||||
if matches_zone(server_name) {
|
||||
domains.insert(server_name.to_string());
|
||||
}
|
||||
|
||||
// Add mail exchangers
|
||||
for exchanger in &server.core.network.info.mxs {
|
||||
if let Some(exchanger) = &exchanger.hostname
|
||||
&& matches_zone(exchanger)
|
||||
{
|
||||
domains.insert(exchanger.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Add service hosts
|
||||
for (_, service) in &server.core.network.info.services {
|
||||
if let Some(service) = &service.hostname
|
||||
&& matches_zone(service)
|
||||
{
|
||||
domains.insert(service.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
domains.into_iter().collect()
|
||||
}
|
||||
} else {
|
||||
hostnames
|
||||
.iter()
|
||||
.map(|h| {
|
||||
if h.contains('.') {
|
||||
h.clone()
|
||||
} else {
|
||||
format!("{h}.{domain}")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn renew(
|
||||
&self,
|
||||
server: &Server,
|
||||
domains: Vec<String>,
|
||||
reuse_key_pem: Option<String>,
|
||||
dns_parameters: Option<AcmeDnsParameters>,
|
||||
) -> AcmeResult<PemCert> {
|
||||
let mut params = CertificateParams::new(domains.clone()).map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to create certificate params: {}", err))
|
||||
})?;
|
||||
params.distinguished_name = DistinguishedName::new();
|
||||
let key_pair = match reuse_key_pem {
|
||||
Some(pem) => KeyPair::from_pem(&pem).map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to load private key for reuse: {}", err))
|
||||
})?,
|
||||
None => KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to generate key pair: {}", err))
|
||||
})?,
|
||||
};
|
||||
let response = self.new_order(domains.clone()).await?;
|
||||
let order_url = response.location;
|
||||
let mut order = response.body;
|
||||
let mut retry_after = None;
|
||||
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::OrderStart),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Details = order_url.to_string(),
|
||||
Hostname = domains.as_slice(),
|
||||
Type = self.challenge.as_str(),
|
||||
);
|
||||
|
||||
loop {
|
||||
match order.status {
|
||||
OrderStatus::Pending => {
|
||||
if matches!(self.challenge, ChallengeType::Dns01) {
|
||||
for url in &order.authorizations {
|
||||
self.authorize(server, url, dns_parameters.as_ref()).await?;
|
||||
}
|
||||
} else {
|
||||
let auth_futures = order
|
||||
.authorizations
|
||||
.iter()
|
||||
.map(|url| self.authorize(server, url, dns_parameters.as_ref()));
|
||||
try_join_all(auth_futures).await?;
|
||||
}
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthCompleted),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Hostname = domains.as_slice(),
|
||||
);
|
||||
let response = self.order(&order_url).await?;
|
||||
order = response.body;
|
||||
retry_after = response.retry_after;
|
||||
}
|
||||
OrderStatus::Processing => {
|
||||
for i in 0u64..10 {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::OrderProcessing),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Hostname = domains.as_slice(),
|
||||
Total = i,
|
||||
);
|
||||
|
||||
tokio::time::sleep(
|
||||
retry_after.unwrap_or_else(|| Duration::from_secs(1u64 << i)),
|
||||
)
|
||||
.await;
|
||||
let response = self
|
||||
.order(&order_url)
|
||||
.await?
|
||||
.assert_reasonable_retry_after(self.max_retries)?;
|
||||
order = response.body;
|
||||
retry_after = response.retry_after;
|
||||
if order.status != OrderStatus::Processing {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if order.status == OrderStatus::Processing {
|
||||
return Err(AcmeError::OrderTimeout {
|
||||
max_retries: self.max_retries,
|
||||
});
|
||||
}
|
||||
}
|
||||
OrderStatus::Ready => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::OrderReady),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Hostname = domains.as_slice(),
|
||||
);
|
||||
|
||||
let csr = params.serialize_request(&key_pair).map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to serialize CSR: {}", err))
|
||||
})?;
|
||||
let csr = csr.der().to_vec();
|
||||
order = self.finalize(order.finalize, csr).await?.body;
|
||||
}
|
||||
OrderStatus::Valid { certificate } => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::OrderValid),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Hostname = domains.as_slice(),
|
||||
);
|
||||
|
||||
let certificate = self.select_certificate(&domains, certificate).await?;
|
||||
|
||||
return Ok(PemCert {
|
||||
certificate,
|
||||
private_key: key_pair.serialize_pem(),
|
||||
});
|
||||
}
|
||||
OrderStatus::Invalid => {
|
||||
let reason = if let Some(reason) = order.error {
|
||||
reason.to_string()
|
||||
} else {
|
||||
"Unknown reason".to_string()
|
||||
};
|
||||
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::OrderInvalid),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Details = order_url.to_string(),
|
||||
Hostname = domains.as_slice(),
|
||||
Reason = reason.clone(),
|
||||
);
|
||||
|
||||
return Err(AcmeError::OrderInvalid(reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn authorize(
|
||||
&self,
|
||||
server: &Server,
|
||||
url: &String,
|
||||
dns_parameters: Option<&AcmeDnsParameters>,
|
||||
) -> AcmeResult<()> {
|
||||
let response = self
|
||||
.auth(url)
|
||||
.await?
|
||||
.assert_reasonable_retry_after(self.max_retries)?;
|
||||
let mut retry_after = response.retry_after;
|
||||
let auth = response.body;
|
||||
|
||||
let (domain, challenge_url) = match auth.status {
|
||||
AuthStatus::Pending => {
|
||||
let Identifier::Dns(domain) = auth.identifier;
|
||||
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthStart),
|
||||
Hostname = domain.to_string(),
|
||||
Type = self.challenge.as_str(),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
);
|
||||
|
||||
let challenge = auth
|
||||
.challenges
|
||||
.iter()
|
||||
.find(|c| c.typ == self.challenge)
|
||||
.ok_or(AcmeError::ChallengeNotSupported {
|
||||
requested: self.challenge,
|
||||
supported: auth.challenges.clone(),
|
||||
})?;
|
||||
|
||||
match &self.challenge {
|
||||
ChallengeType::TlsAlpn01 => {
|
||||
server
|
||||
.in_memory_store()
|
||||
.key_set(
|
||||
KeyValue::with_prefix(
|
||||
KV_ACME,
|
||||
&domain,
|
||||
self.tls_alpn_key(challenge, domain.clone())?,
|
||||
)
|
||||
.expires(3600),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ChallengeType::Http01 => {
|
||||
server
|
||||
.in_memory_store()
|
||||
.key_set(
|
||||
KeyValue::with_prefix(
|
||||
KV_ACME,
|
||||
challenge.token.as_deref().ok_or_else(|| {
|
||||
AcmeError::Invalid(
|
||||
"Missing http-01 challenge token in response"
|
||||
.to_string(),
|
||||
)
|
||||
})?,
|
||||
self.http_proof(challenge)?,
|
||||
)
|
||||
.expires(3600),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ChallengeType::Dns01 => {
|
||||
let dns_parameters = dns_parameters.unwrap();
|
||||
let domain = domain.strip_prefix("*.").unwrap_or(&domain);
|
||||
|
||||
let zone = dns_parameters
|
||||
.origin
|
||||
.as_deref()
|
||||
.or_else(|| psl::domain_str(domain))
|
||||
.unwrap_or(domain);
|
||||
|
||||
let proof = self.dns_proof(challenge)?;
|
||||
let challenge_name = format!("_acme-challenge.{}", domain);
|
||||
dns_parameters
|
||||
.updater
|
||||
.set_rrset(
|
||||
zone,
|
||||
&challenge_name,
|
||||
dns_update::DnsRecordType::TXT,
|
||||
vec![DnsRecord::TXT(proof.clone())],
|
||||
)
|
||||
.await
|
||||
.map_err(AcmeError::Dns)?;
|
||||
dns_parameters
|
||||
.updater
|
||||
.wait_for_txt_propagation(&challenge_name, zone, &proof)
|
||||
.await;
|
||||
}
|
||||
ChallengeType::DnsPersist01 => {}
|
||||
ChallengeType::Unknown => unreachable!(),
|
||||
}
|
||||
|
||||
self.challenge(&challenge.url).await?;
|
||||
(domain, challenge.url.clone())
|
||||
}
|
||||
AuthStatus::Valid => return Ok(()),
|
||||
_ => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthError),
|
||||
Hostname = auth.identifier.hostname().to_string(),
|
||||
Type = self.challenge.as_str(),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Details = url.to_string(),
|
||||
Reason = auth.to_error(),
|
||||
);
|
||||
|
||||
return Err(AcmeError::AuthInvalid(auth.to_error()));
|
||||
}
|
||||
};
|
||||
|
||||
for i in 0u64..5 {
|
||||
tokio::time::sleep(retry_after.unwrap_or_else(|| Duration::from_secs(1u64 << i))).await;
|
||||
let response = self
|
||||
.auth(url)
|
||||
.await?
|
||||
.assert_reasonable_retry_after(self.max_retries)?;
|
||||
retry_after = response.retry_after;
|
||||
|
||||
match response.body.status {
|
||||
AuthStatus::Pending => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthPending),
|
||||
Hostname = domain.to_string(),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Total = i,
|
||||
);
|
||||
|
||||
self.challenge(&challenge_url).await?
|
||||
}
|
||||
AuthStatus::Valid => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthValid),
|
||||
Hostname = domain.to_string(),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthError),
|
||||
Hostname = domain.to_string(),
|
||||
Type = self.challenge.as_str(),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Details = url.to_string(),
|
||||
Reason = response.body.to_error(),
|
||||
);
|
||||
|
||||
return Err(AcmeError::AuthInvalid(response.body.to_error()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::AuthTooManyAttempts),
|
||||
Hostname = domain.to_string(),
|
||||
Type = self.challenge.as_str(),
|
||||
Url = self.directory.new_order.to_string(),
|
||||
Details = url.to_string(),
|
||||
Total = 5u64,
|
||||
);
|
||||
|
||||
Err(AcmeError::AuthTimeout {
|
||||
max_retries: self.max_retries,
|
||||
})
|
||||
}
|
||||
|
||||
async fn select_certificate(&self, domains: &[String], url: String) -> AcmeResult<String> {
|
||||
let response = self.certificate(url).await?;
|
||||
let Some(preferred) = self.preferred_chain.as_deref() else {
|
||||
return Ok(response.body);
|
||||
};
|
||||
|
||||
if chain_matches(&response.body, preferred) {
|
||||
return Ok(response.body);
|
||||
}
|
||||
|
||||
for alternate in &response.alternates {
|
||||
match self.certificate(alternate).await {
|
||||
Ok(alternate) if chain_matches(&alternate.body, preferred) => {
|
||||
return Ok(alternate.body);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::ProcessCert),
|
||||
Url = alternate.to_string(),
|
||||
Hostname = domains,
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::ProcessCert),
|
||||
Hostname = domains,
|
||||
Reason = format!(
|
||||
"Preferred certificate chain '{preferred}' not offered by the CA; using the default chain",
|
||||
),
|
||||
);
|
||||
|
||||
Ok(response.body)
|
||||
}
|
||||
}
|
||||
|
||||
fn chain_matches(pem_chain: &str, preferred: &str) -> bool {
|
||||
let Ok(blocks) = pem::parse_many(pem_chain) else {
|
||||
return false;
|
||||
};
|
||||
let Some(top) = blocks.last() else {
|
||||
return false;
|
||||
};
|
||||
let Ok((_, cert)) = parse_x509_certificate(top.contents()) else {
|
||||
return false;
|
||||
};
|
||||
cert.issuer()
|
||||
.iter_common_name()
|
||||
.filter_map(|cn| cn.as_str().ok())
|
||||
.any(|cn| cn == preferred)
|
||||
}
|
||||
|
||||
impl ParsedCert {
|
||||
pub fn parse(certificate: impl AsRef<[u8]>) -> AcmeResult<ParsedCert> {
|
||||
let der = pem::parse_many(certificate)
|
||||
.map_err(|err| AcmeError::Crypto(format!("Failed to parse PEM: {}", err)))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| AcmeError::Crypto("No certificates found in PEM".to_string()))?;
|
||||
Self::parse_der(der.contents())
|
||||
}
|
||||
|
||||
pub fn parse_der(der: &[u8]) -> AcmeResult<ParsedCert> {
|
||||
parse_x509_certificate(der)
|
||||
.map_err(|err| AcmeError::Crypto(format!("Failed to parse X.509 certificate: {}", err)))
|
||||
.and_then(|(_, cert)| {
|
||||
// Add CNs and SANs to the list of names
|
||||
let mut names: BTreeSet<String> = BTreeSet::new();
|
||||
for name in cert.subject().iter_common_name() {
|
||||
if let Ok(name) = name.as_str() {
|
||||
names.insert(name.into());
|
||||
}
|
||||
}
|
||||
for ext in cert.extensions() {
|
||||
if let ParsedExtension::SubjectAlternativeName(san) = ext.parsed_extension() {
|
||||
for name in &san.general_names {
|
||||
let name = match name {
|
||||
GeneralName::DNSName(name) => (*name).into(),
|
||||
GeneralName::IPAddress(ip) => match ip.len() {
|
||||
4 => Ipv4Addr::from(<[u8; 4]>::try_from(*ip).unwrap())
|
||||
.to_string(),
|
||||
16 => Ipv6Addr::from(<[u8; 16]>::try_from(*ip).unwrap())
|
||||
.to_string(),
|
||||
_ => continue,
|
||||
},
|
||||
_ => {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
names.insert(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ParsedCert {
|
||||
sans: names.into_iter().collect(),
|
||||
issuer: cert.tbs_certificate.issuer().to_string(),
|
||||
valid_not_before: Utc
|
||||
.timestamp_opt(cert.tbs_certificate.validity().not_before.timestamp(), 0)
|
||||
.single()
|
||||
.ok_or_else(|| {
|
||||
AcmeError::Crypto(
|
||||
"Certificate not_before time is out of range".to_string(),
|
||||
)
|
||||
})?,
|
||||
valid_not_after: Utc
|
||||
.timestamp_opt(cert.tbs_certificate.validity().not_after.timestamp(), 0)
|
||||
.single()
|
||||
.ok_or_else(|| {
|
||||
AcmeError::Crypto(
|
||||
"Certificate not_after time is out of range".to_string(),
|
||||
)
|
||||
})?,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::chain_matches;
|
||||
use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, PKCS_ECDSA_P256_SHA256};
|
||||
|
||||
fn self_signed_pem(common_name: &str) -> String {
|
||||
let mut params = CertificateParams::new(vec!["host.example".to_string()]).unwrap();
|
||||
let mut dn = DistinguishedName::new();
|
||||
dn.push(DnType::CommonName, common_name);
|
||||
params.distinguished_name = dn;
|
||||
let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
|
||||
params.self_signed(&key_pair).unwrap().pem()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_top_certificate_issuer() {
|
||||
let chain = self_signed_pem("ISRG Root X1");
|
||||
assert!(chain_matches(&chain, "ISRG Root X1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_is_case_sensitive() {
|
||||
let chain = self_signed_pem("ISRG Root X1");
|
||||
assert!(!chain_matches(&chain, "isrg root x1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_is_exact_not_substring() {
|
||||
let chain = self_signed_pem("ISRG Root X10");
|
||||
assert!(!chain_matches(&chain, "ISRG Root X1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_match_unrelated_issuer() {
|
||||
let chain = self_signed_pem("ISRG Root X2");
|
||||
assert!(!chain_matches(&chain, "ISRG Root X1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_topmost_certificate_not_leaf() {
|
||||
let leaf = self_signed_pem("Leaf Issuer");
|
||||
let top = self_signed_pem("ISRG Root X1");
|
||||
let chain = format!("{leaf}{top}");
|
||||
assert!(chain_matches(&chain, "ISRG Root X1"));
|
||||
assert!(!chain_matches(&chain, "Leaf Issuer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unparseable_chain() {
|
||||
assert!(!chain_matches("not a pem", "ISRG Root X1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
Server,
|
||||
ipc::{BroadcastEvent, RegistryChange},
|
||||
network::acme::{
|
||||
AcmeDnsParameters, AcmeError, AcmeResult, ParsedCert, directory::AcmeRequestBuilder,
|
||||
},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{AcmeChallengeType, AcmeRenewBefore, DnsRecordType},
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
AcmeProvider, Certificate, CertificateManagement, DnsManagement, Domain, PublicText,
|
||||
PublicTextValue, SecretText, SecretTextValue, SystemSettings, Task, TaskDnsManagement,
|
||||
TaskDomainManagement, TaskStatus,
|
||||
},
|
||||
},
|
||||
types::{datetime::UTCDateTime, id::ObjectId, map::Map},
|
||||
};
|
||||
use store::{
|
||||
registry::{
|
||||
RegistryQuery,
|
||||
write::{RegistryWrite, RegistryWriteResult},
|
||||
},
|
||||
write::now,
|
||||
};
|
||||
use types::id::Id;
|
||||
|
||||
impl Server {
|
||||
pub async fn acme_renew(&self, domain_id: Id) -> AcmeResult<Vec<Task>> {
|
||||
let Some(domain) = self.registry().object::<Domain>(domain_id).await? else {
|
||||
return Err(AcmeError::Invalid(format!(
|
||||
"Domain with ID {} not found",
|
||||
domain_id
|
||||
)));
|
||||
};
|
||||
let cert = match domain.certificate_management {
|
||||
CertificateManagement::Manual => {
|
||||
return Err(AcmeError::Invalid(
|
||||
"ACME not configured for domain".to_string(),
|
||||
));
|
||||
}
|
||||
CertificateManagement::Automatic(props) => props,
|
||||
};
|
||||
let Some(acme_provider) = self
|
||||
.registry()
|
||||
.object::<AcmeProvider>(cert.acme_provider_id)
|
||||
.await?
|
||||
else {
|
||||
return Err(AcmeError::Invalid(format!(
|
||||
"ACME provider with ID {} not found",
|
||||
cert.acme_provider_id
|
||||
)));
|
||||
};
|
||||
let challenge_type = acme_provider.challenge_type;
|
||||
let renew_before = acme_provider.renew_before;
|
||||
let reuse_key = acme_provider.reuse_key;
|
||||
let request = AcmeRequestBuilder::new(acme_provider).await?;
|
||||
let domains = request.build_domains(
|
||||
self,
|
||||
&domain.name,
|
||||
&cert.subject_alternative_names.into_inner(),
|
||||
);
|
||||
|
||||
if let Some(renew_at) = self
|
||||
.acme_certificate_renewal_due(&domains, renew_before, now())
|
||||
.await?
|
||||
{
|
||||
return Err(AcmeError::NotDue(format!(
|
||||
"Certificate for domain {} is still valid; renewal is not due until {}",
|
||||
domain.name,
|
||||
UTCDateTime::from_timestamp(renew_at as i64)
|
||||
)));
|
||||
}
|
||||
|
||||
let dns_parameters = match &domain.dns_management {
|
||||
DnsManagement::Automatic(props) if challenge_type == AcmeChallengeType::Dns01 => {
|
||||
match self.build_dns_updater(props.dns_server_id).await? {
|
||||
Ok(updater) => Some(AcmeDnsParameters {
|
||||
updater,
|
||||
origin: props.origin.clone(),
|
||||
}),
|
||||
Err(err) => {
|
||||
return Err(AcmeError::Invalid(format!(
|
||||
"Failed to build DNS updater: {}",
|
||||
err
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if challenge_type == AcmeChallengeType::Dns01 && dns_parameters.is_none() {
|
||||
return Err(AcmeError::Invalid(
|
||||
"ACME provider requires DNS challenge but a DNS provider was not configured"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let reuse_key_pem = if reuse_key {
|
||||
match self.acme_certificate_by_domains(&domains).await? {
|
||||
Some(certificate) => certificate
|
||||
.private_key
|
||||
.secret()
|
||||
.await
|
||||
.map(std::borrow::Cow::into_owned)
|
||||
.map_err(|err| {
|
||||
AcmeError::Crypto(format!("Failed to load certificate private key: {err}"))
|
||||
})?
|
||||
.into(),
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let pem_cert = request
|
||||
.renew(self, domains, reuse_key_pem, dns_parameters)
|
||||
.await?;
|
||||
let parsed_cert = ParsedCert::parse(&pem_cert.certificate)?;
|
||||
let mut new_sans = parsed_cert.sans.clone();
|
||||
new_sans.sort();
|
||||
let certificate = Certificate {
|
||||
private_key: SecretText::Text(SecretTextValue {
|
||||
secret: pem_cert.private_key,
|
||||
}),
|
||||
certificate: PublicText::Text(PublicTextValue {
|
||||
value: pem_cert.certificate,
|
||||
}),
|
||||
issuer: parsed_cert.issuer,
|
||||
not_valid_after: UTCDateTime::from_timestamp(parsed_cert.valid_not_after.timestamp()),
|
||||
not_valid_before: UTCDateTime::from_timestamp(parsed_cert.valid_not_before.timestamp()),
|
||||
subject_alternative_names: Map::new(parsed_cert.sans),
|
||||
};
|
||||
let now = now();
|
||||
let expires_in = (parsed_cert.valid_not_after.timestamp() as u64).saturating_sub(now);
|
||||
if expires_in < 3600 {
|
||||
return Err(AcmeError::Invalid(format!(
|
||||
"Certificate expires in {} seconds, expected at least 3600 seconds",
|
||||
expires_in
|
||||
)));
|
||||
}
|
||||
|
||||
match self
|
||||
.registry()
|
||||
.write(RegistryWrite::insert(&certificate.into()))
|
||||
.await?
|
||||
{
|
||||
RegistryWriteResult::Success(id) => {
|
||||
// Repoint the default certificate to the renewed object when it
|
||||
// tracks the same SAN set, so its id does not go stale
|
||||
if let Some(old) = self
|
||||
.registry()
|
||||
.get(ObjectType::SystemSettings.singleton())
|
||||
.await?
|
||||
{
|
||||
let mut settings = SystemSettings::from(old.clone());
|
||||
if let Some(default_id) = settings.default_certificate_id
|
||||
&& let Some(default_cert) =
|
||||
self.registry().object::<Certificate>(default_id).await?
|
||||
{
|
||||
let mut default_sans =
|
||||
default_cert.subject_alternative_names.clone().into_inner();
|
||||
default_sans.sort();
|
||||
if default_sans == new_sans {
|
||||
settings.default_certificate_id = Some(id);
|
||||
if let Err(err) = self
|
||||
.registry()
|
||||
.write(RegistryWrite::update(
|
||||
Id::singleton(),
|
||||
&settings.into(),
|
||||
&old,
|
||||
))
|
||||
.await
|
||||
{
|
||||
trc::error!(
|
||||
err.details(
|
||||
"Failed to update default certificate after ACME renewal."
|
||||
)
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reload registry
|
||||
let change = RegistryChange::Insert(ObjectId::new(ObjectType::Certificate, id));
|
||||
Box::pin(self.reload_registry(change)).await?;
|
||||
self.cluster_broadcast(BroadcastEvent::RegistryChange(change))
|
||||
.await;
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
let renew_at = Self::acme_renewal_due_at(
|
||||
parsed_cert.valid_not_before.timestamp(),
|
||||
parsed_cert.valid_not_after.timestamp(),
|
||||
renew_before,
|
||||
);
|
||||
|
||||
trc::event!(
|
||||
Acme(trc::AcmeEvent::OrderCompleted),
|
||||
Domain = domain.name.clone(),
|
||||
Hostname = new_sans.as_slice(),
|
||||
Id = id.to_string(),
|
||||
ValidFrom =
|
||||
trc::Value::Timestamp(parsed_cert.valid_not_before.timestamp() as u64),
|
||||
ValidTo = trc::Value::Timestamp(parsed_cert.valid_not_after.timestamp() as u64),
|
||||
NextRetry = trc::Value::Timestamp(renew_at as u64),
|
||||
);
|
||||
|
||||
tasks.push(Task::AcmeRenewal(TaskDomainManagement {
|
||||
domain_id,
|
||||
status: TaskStatus::at(renew_at),
|
||||
}));
|
||||
|
||||
// Update TLSA records
|
||||
if let DnsManagement::Automatic(props) = &domain.dns_management
|
||||
&& props.publish_records.contains(&DnsRecordType::Tlsa)
|
||||
{
|
||||
tasks.push(Task::DnsManagement(TaskDnsManagement {
|
||||
domain_id,
|
||||
on_success_renew_certificate: false,
|
||||
status: TaskStatus::now(),
|
||||
update_records: Map::new(vec![DnsRecordType::Tlsa]),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(tasks)
|
||||
}
|
||||
err => Err(AcmeError::Registry(err)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn acme_certificate_by_domains(
|
||||
&self,
|
||||
domains: &[String],
|
||||
) -> AcmeResult<Option<Certificate>> {
|
||||
let mut wanted = domains.iter().collect::<Vec<_>>();
|
||||
wanted.sort();
|
||||
let Some(reference) = wanted.first() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let candidate_ids = self
|
||||
.registry()
|
||||
.query::<Vec<Id>>(
|
||||
RegistryQuery::new(ObjectType::Certificate)
|
||||
.text(Property::SubjectAlternativeNames, reference.as_str()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for id in candidate_ids {
|
||||
let Some(certificate) = self.registry().object::<Certificate>(id).await? else {
|
||||
continue;
|
||||
};
|
||||
let mut sans = certificate
|
||||
.subject_alternative_names
|
||||
.iter()
|
||||
.collect::<Vec<_>>();
|
||||
sans.sort();
|
||||
if sans == wanted {
|
||||
return Ok(Some(certificate));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn acme_certificate_renewal_due(
|
||||
&self,
|
||||
domains: &[String],
|
||||
renew_before: AcmeRenewBefore,
|
||||
now: u64,
|
||||
) -> AcmeResult<Option<u64>> {
|
||||
let now = now as i64;
|
||||
let Some(certificate) = self.acme_certificate_by_domains(domains).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let not_valid_after = certificate.not_valid_after.timestamp();
|
||||
if not_valid_after <= now {
|
||||
return Ok(None);
|
||||
}
|
||||
let not_valid_before = certificate.not_valid_before.timestamp();
|
||||
let renew_at = Self::acme_renewal_due_at(not_valid_before, not_valid_after, renew_before);
|
||||
Ok(if now < renew_at {
|
||||
Some(renew_at as u64)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn acme_renewal_due_at(
|
||||
not_valid_before: i64,
|
||||
not_valid_after: i64,
|
||||
renew_before: AcmeRenewBefore,
|
||||
) -> i64 {
|
||||
let total = not_valid_after.saturating_sub(not_valid_before);
|
||||
let (numerator, denominator) = match renew_before {
|
||||
AcmeRenewBefore::R12 => (1, 2),
|
||||
AcmeRenewBefore::R23 => (2, 3),
|
||||
AcmeRenewBefore::R34 => (3, 4),
|
||||
AcmeRenewBefore::R45 => (4, 5),
|
||||
};
|
||||
not_valid_before + total * numerator / denominator
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
KV_ACME, Server,
|
||||
network::acme::{SerializedCert, StaticResolver, directory::ACME_TLS_ALPN_NAME},
|
||||
};
|
||||
use rustls::{
|
||||
ServerConfig,
|
||||
crypto::aws_lc_rs::sign::any_ecdsa_type,
|
||||
server::{ClientHello, ResolvesServerCert},
|
||||
sign::CertifiedKey,
|
||||
};
|
||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
use std::sync::Arc;
|
||||
use store::{
|
||||
dispatch::lookup::KeyValue,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::AcmeEvent;
|
||||
|
||||
impl Server {
|
||||
pub(crate) async fn build_acme_certificate(&self, domain: &str) -> Option<Arc<CertifiedKey>> {
|
||||
match self
|
||||
.in_memory_store()
|
||||
.key_get::<Archive<AlignedBytes>>(KeyValue::<()>::build_key(KV_ACME, domain))
|
||||
.await
|
||||
{
|
||||
Ok(Some(cert_)) => match cert_.unarchive::<SerializedCert>() {
|
||||
Ok(cert) => {
|
||||
match any_ecdsa_type(&PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(
|
||||
cert.private_key.as_ref(),
|
||||
))) {
|
||||
Ok(key) => Some(Arc::new(CertifiedKey::new(
|
||||
vec![CertificateDer::from(cert.certificate.to_vec())],
|
||||
key,
|
||||
))),
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::Error),
|
||||
Domain = domain.to_string(),
|
||||
Reason = err.to_string(),
|
||||
Details = "Failed to parse private key"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::Error),
|
||||
Domain = domain.to_string(),
|
||||
CausedBy = err,
|
||||
Details = "Failed to unarchive certificate"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Acme(AcmeEvent::Error),
|
||||
Domain = domain.to_string(),
|
||||
CausedBy = err
|
||||
);
|
||||
None
|
||||
}
|
||||
Ok(None) => {
|
||||
trc::event!(Acme(AcmeEvent::TokenNotFound), Domain = domain.to_string());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn has_acme_tls_providers(&self) -> bool {
|
||||
self.core.network.has_acme_tls_challenge
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn has_acme_http_providers(&self) -> bool {
|
||||
self.core.network.has_acme_http_challenge
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvesServerCert for StaticResolver {
|
||||
fn resolve(&self, _: ClientHello) -> Option<Arc<CertifiedKey>> {
|
||||
self.key.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_acme_static_resolver(key: Option<Arc<CertifiedKey>>) -> Arc<ServerConfig> {
|
||||
let mut challenge = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_cert_resolver(Arc::new(StaticResolver { key }));
|
||||
challenge.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec());
|
||||
Arc::new(challenge)
|
||||
}
|
||||
|
||||
pub trait IsTlsAlpnChallenge {
|
||||
fn is_tls_alpn_challenge(&self) -> bool;
|
||||
}
|
||||
|
||||
impl IsTlsAlpnChallenge for ClientHello<'_> {
|
||||
fn is_tls_alpn_challenge(&self) -> bool {
|
||||
self.alpn().into_iter().flatten().eq([ACME_TLS_ALPN_NAME])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user