diff --git a/README.md b/README.md index 37f2e39..5cd5628 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,13 @@ specification. * Support for external account binding * Support for certificate revocation * Support for the [ACME renewal information (ARI)] extension +* Support for the [profiles] extension * Uses hyper with rustls and Tokio for HTTP requests * Uses *ring* or aws-lc-rs for ECDSA signing * Minimum supported Rust version (MSRV): 1.70 [ACME renewal information (ARI)]: https://www.ietf.org/archive/id/draft-ietf-acme-ari-08.html +[profiles]: https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/ ## Cargo features diff --git a/src/lib.rs b/src/lib.rs index e00207e..225ba70 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,11 +36,11 @@ pub use types::RenewalInfo; pub use types::{ AccountCredentials, Authorization, AuthorizationState, AuthorizationStatus, AuthorizedIdentifier, CertificateIdentifier, Challenge, ChallengeType, Error, Identifier, - LetsEncrypt, NewAccount, NewOrder, OrderState, OrderStatus, Problem, RevocationReason, - RevocationRequest, ZeroSsl, + LetsEncrypt, NewAccount, NewOrder, OrderState, OrderStatus, Problem, ProfileMeta, + RevocationReason, RevocationRequest, ZeroSsl, }; use types::{ - DirectoryUrls, Empty, FinalizeRequest, Header, JoseJson, Jwk, KeyOrKeyId, NewAccountPayload, + Directory, Empty, FinalizeRequest, Header, JoseJson, Jwk, KeyOrKeyId, NewAccountPayload, Signer, SigningAlgorithm, }; @@ -582,7 +582,7 @@ impl Account { .map(|eak| { JoseJson::new( Some(&Jwk::new(&key.inner)), - eak.header(None, &client.urls.new_account), + eak.header(None, &client.directory.new_account), eak, ) }) @@ -590,7 +590,7 @@ impl Account { }; let rsp = client - .post(Some(&payload), None, &key, &client.urls.new_account) + .post(Some(&payload), None, &key, &client.directory.new_account) .await?; let account_url = rsp @@ -630,13 +630,13 @@ impl Account { /// /// Returns an [`Order`] instance. Use the [`Order::state()`] method to inspect its state. pub async fn new_order(&self, order: &NewOrder<'_>) -> Result { - if order.replaces.is_some() && self.inner.client.urls.renewal_info.is_none() { + if order.replaces.is_some() && self.inner.client.directory.renewal_info.is_none() { return Err(Error::Unsupported("ACME renewal information (ARI)")); } let rsp = self .inner - .post(Some(order), None, &self.inner.client.urls.new_order) + .post(Some(order), None, &self.inner.client.directory.new_order) .await?; let nonce = nonce_from_response(&rsp); @@ -694,7 +694,7 @@ impl Account { /// Revokes a previously issued certificate pub async fn revoke<'a>(&'a self, payload: &RevocationRequest<'a>) -> Result<(), Error> { - let revoke_url = match self.inner.client.urls.revoke_cert.as_deref() { + let revoke_url = match self.inner.client.directory.revoke_cert.as_deref() { Some(url) => url, // This happens because the current account credentials were deserialized from an // older version which only serialized a subset of the directory URLs. You should @@ -726,7 +726,7 @@ impl Account { &self, certificate_id: &CertificateIdentifier<'_>, ) -> Result { - let renewal_info_url = match self.inner.client.urls.renewal_info.as_deref() { + let renewal_info_url = match self.inner.client.directory.renewal_info.as_deref() { Some(url) => url, None => return Err(Error::Unsupported("ACME renewal information (ARI)")), }; @@ -771,6 +771,17 @@ impl Account { Ok(()) } + /// Yield the profiles supported according to the account's server directory + pub fn profiles(&self) -> impl Iterator> { + self.inner + .client + .directory + .meta + .profiles + .iter() + .map(|(name, description)| ProfileMeta { name, description }) + } + /// Get the account ID pub fn id(&self) -> &str { &self.inner.id @@ -793,7 +804,7 @@ impl AccountInner { key: Key::from_pkcs8_der(credentials.key_pkcs8.as_ref())?, client: match (credentials.directory, credentials.urls) { (Some(server_url), _) => Client::new(&server_url, http).await?, - (None, Some(urls)) => Client { http, urls }, + (None, Some(directory)) => Client { http, directory }, (None, None) => return Err("no server URLs found".into()), }, }) @@ -839,7 +850,7 @@ impl Signer for AccountInner { struct Client { http: Box, - urls: DirectoryUrls, + directory: Directory, } impl Client { @@ -852,7 +863,7 @@ impl Client { let body = rsp.body().await.map_err(Error::Other)?; Ok(Client { http, - urls: serde_json::from_slice(&body)?, + directory: serde_json::from_slice(&body)?, }) } @@ -918,7 +929,7 @@ impl Client { let request = Request::builder() .method(Method::HEAD) - .uri(&self.urls.new_nonce) + .uri(&self.directory.new_nonce) .body(Full::default()) .expect("infallible error should not occur"); @@ -941,7 +952,7 @@ impl fmt::Debug for Client { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Client") .field("client", &"..") - .field("urls", &self.urls) + .field("directory", &self.directory) .finish() } } diff --git a/src/types.rs b/src/types.rs index 9dd6584..ab8c648 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use std::fmt; -use std::fmt::Write; +use std::collections::HashMap; +use std::fmt::{self, Write}; use std::net::IpAddr; use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine}; @@ -98,7 +98,7 @@ pub struct AccountCredentials { /// We never serialize `urls` by default, but we support deserializing them /// in order to support serialized data from older versions of the library. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) urls: Option, + pub(crate) urls: Option, } mod pkcs8_serde { @@ -366,6 +366,9 @@ pub struct OrderState { #[serde(deserialize_with = "deserialize_static_certificate_identifier")] #[serde(default)] pub replaces: Option>, + /// The profile to be used for the order + #[serde(default)] + pub profile: Option, } /// A wrapper for [`AuthorizationState`] as held in the [`OrderState`] @@ -408,6 +411,7 @@ pub struct NewOrder<'a> { pub(crate) replaces: Option>, /// Identifiers to be included in the order identifiers: &'a [Identifier], + profile: Option<&'a str>, } impl<'a> NewOrder<'a> { @@ -418,6 +422,7 @@ impl<'a> NewOrder<'a> { Self { identifiers, replaces: None, + profile: None, } } @@ -433,11 +438,20 @@ impl<'a> NewOrder<'a> { /// present in the certificate being replaced. If the ACME CA does not support the /// ACME renewal information (ARI) extension, the [crate::Account::new_order()] method will /// return an error. - pub fn replaces(&mut self, replaces: CertificateIdentifier<'a>) -> &mut Self { + pub fn replaces(mut self, replaces: CertificateIdentifier<'a>) -> Self { self.replaces = Some(replaces); self } + /// Set the profile to be used for the order + /// + /// [`Account::new_order()`] will yield an error if the ACME server does not support + /// the profiles extension or if the specified profile is not supported. + pub fn profile(mut self, profile: &'a str) -> Self { + self.profile = Some(profile); + self + } + /// Identifiers to be included in the order pub fn identifiers(&self) -> &[Identifier] { self.identifiers @@ -517,12 +531,12 @@ pub struct NewAccount<'a> { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct DirectoryUrls { +pub(crate) struct Directory { pub(crate) new_nonce: String, pub(crate) new_account: String, pub(crate) new_order: String, // The fields below were added later and old `AccountCredentials` may not have it. - // Newer deserialized account credentials grab a fresh set of `DirectoryUrls` on + // Newer deserialized account credentials grab a fresh set of `Directory` on // deserialization, so they should be fine. Newer fields should be optional, too. pub(crate) new_authz: Option, pub(crate) revoke_cert: Option, @@ -531,6 +545,22 @@ pub(crate) struct DirectoryUrls { // // pub(crate) renewal_info: Option, + #[serde(default)] + pub(crate) meta: Meta, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(crate) struct Meta { + #[serde(default)] + pub(crate) profiles: HashMap, +} + +/// Profile meta information from the server directory +#[allow(missing_docs)] +#[derive(Clone, Copy, Debug)] +pub struct ProfileMeta<'a> { + pub name: &'a str, + pub description: &'a str, } #[derive(Serialize)] diff --git a/tests/pebble.rs b/tests/pebble.rs index 60733be..d4ab086 100644 --- a/tests/pebble.rs +++ b/tests/pebble.rs @@ -232,12 +232,34 @@ async fn replacement_order() -> Result<(), Box> { assert!(renewal_info.suggested_window.start < OffsetDateTime::now_utc()); // So, let's go ahead and issue a replacement certificate. - env.test::(NewOrder::new(&dns_identifiers(names)).replaces(initial_cert_id)) + env.test::(&NewOrder::new(&dns_identifiers(names)).replaces(initial_cert_id)) .await?; Ok(()) } +/// Test order profiles +#[cfg(feature = "x509-parser")] +#[tokio::test] +#[ignore] +async fn profiles() -> Result<(), Box> { + try_tracing_init(); + + // Creat an env/initial account + let mut env = Environment::new(EnvironmentConfig::default()).await?; + let identifiers = dns_identifiers(["example.com"]); + let cert = env + .test::(&NewOrder::new(&identifiers).profile("shortlived")) + .await?; + + let (_, cert) = x509_parser::parse_x509_certificate(cert.as_ref())?; + let validity = cert.validity.time_to_expiration().unwrap(); + let default_profile = env.config.pebble.profiles.get("default").unwrap(); + assert!(validity < default_profile.validity_period); + + Ok(()) +} + /// Test that it is possible to deactivate an order's authorizations #[tokio::test] #[ignore]