ACME: Allow specifying a preferred certificate chain
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
use super::jose::{
|
||||
key_authorization, key_authorization_sha256, key_authorization_sha256_base64, sign,
|
||||
};
|
||||
use crate::network::acme::http::{get_header, https, parse_retry_after};
|
||||
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,
|
||||
@@ -35,12 +35,14 @@ pub struct AcmeRequestBuilder {
|
||||
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;
|
||||
@@ -65,6 +67,7 @@ impl AcmeRequestBuilder {
|
||||
kid: provider.account_uri,
|
||||
challenge: provider.challenge_type.into(),
|
||||
max_retries: provider.max_retries as u32,
|
||||
preferred_chain: provider.preferred_chain,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,6 +88,7 @@ impl AcmeRequestBuilder {
|
||||
Ok(AcmeResponse {
|
||||
location: get_header(&response, "Location").ok(),
|
||||
retry_after: parse_retry_after(&response),
|
||||
alternates: parse_alternate_links(&response),
|
||||
body: response.text().await?,
|
||||
})
|
||||
}
|
||||
@@ -103,6 +107,7 @@ impl AcmeRequestBuilder {
|
||||
)))?,
|
||||
body: serde_json::from_str(&response.body).map_err(AcmeError::Json)?,
|
||||
retry_after: response.retry_after,
|
||||
alternates: response.alternates,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,8 +138,11 @@ impl AcmeRequestBuilder {
|
||||
AcmeResponse::parse(self.request(&url, &payload).await?)
|
||||
}
|
||||
|
||||
pub async fn certificate(&self, url: impl AsRef<str>) -> AcmeResult<String> {
|
||||
Ok(self.request(&url, "").await?.body)
|
||||
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>> {
|
||||
@@ -211,6 +219,7 @@ impl<L, T: DeserializeOwned> AcmeResponse<L, T> {
|
||||
location: input.location,
|
||||
body,
|
||||
retry_after: input.retry_after,
|
||||
alternates: input.alternates,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,40 @@ pub(crate) fn get_header(response: &Response, header: &'static str) -> AcmeResul
|
||||
}
|
||||
}
|
||||
|
||||
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>() {
|
||||
@@ -90,3 +124,70 @@ pub(crate) fn parse_retry_after(response: &Response) -> Option<Duration> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ impl AcmeRequestBuilder {
|
||||
Hostname = domains.as_slice(),
|
||||
);
|
||||
|
||||
let certificate = self.certificate(certificate).await?;
|
||||
let certificate = self.select_certificate(&domains, certificate).await?;
|
||||
|
||||
return Ok(PemCert {
|
||||
certificate,
|
||||
@@ -341,6 +341,60 @@ impl AcmeRequestBuilder {
|
||||
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 {
|
||||
@@ -408,3 +462,56 @@ impl ParsedCert {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user