diff --git a/examples/provision.rs b/examples/provision.rs index bd24356..f6da66b 100644 --- a/examples/provision.rs +++ b/examples/provision.rs @@ -19,16 +19,17 @@ async fn main() -> anyhow::Result<()> { // Alternatively, restore an account from serialized credentials by // using `Account::from_credentials()`. - let (account, credentials) = Account::create( - &NewAccount { - contact: &[], - terms_of_service_agreed: true, - only_return_existing: false, - }, - LetsEncrypt::Staging.url().to_owned(), - None, - ) - .await?; + let (account, credentials) = Account::builder()? + .create( + &NewAccount { + contact: &[], + terms_of_service_agreed: true, + only_return_existing: false, + }, + LetsEncrypt::Staging.url().to_owned(), + None, + ) + .await?; info!( "account credentials:\n\n{}", serde_json::to_string_pretty(&credentials)? diff --git a/src/account.rs b/src/account.rs index 0914089..0ea61ef 100644 --- a/src/account.rs +++ b/src/account.rs @@ -23,8 +23,10 @@ use crate::{BytesResponse, Client, Error, HttpClient, crypto, nonce_from_respons /// An ACME account as described in RFC 8555 (section 7.1.2) /// -/// Create an [`Account`] with [`Account::create()`] or restore it from serialized data -/// by passing deserialized [`AccountCredentials`] to [`Account::from_credentials()`]. +/// Create an [`Account`] via [`Account::builder()`] or [`Account::builder_with_http()`], +/// then use [`AccountBuilder::create()`] to create a new account or restore one from +/// serialized data by passing deserialized [`AccountCredentials`] to +/// [`AccountBuilder::from_credentials()`]. /// /// The [`Account`] type is cheap to clone. /// @@ -35,140 +37,17 @@ pub struct Account { } impl Account { - /// Restore an existing account from the given credentials - /// - /// The [`AccountCredentials`] type is opaque, but supports deserialization. + /// Create an account builder with the default HTTP client #[cfg(feature = "hyper-rustls")] - pub async fn from_credentials(credentials: AccountCredentials) -> Result { - Ok(Self { - inner: Arc::new( - AccountInner::from_credentials(credentials, Box::new(DefaultClient::try_new()?)) - .await?, - ), - }) - } - - /// Restore an existing account from the given credentials and HTTP client - /// - /// The [`AccountCredentials`] type is opaque, but supports deserialization. - pub async fn from_credentials_and_http( - credentials: AccountCredentials, - http: Box, - ) -> Result { - Ok(Self { - inner: Arc::new(AccountInner::from_credentials(credentials, http).await?), + pub fn builder() -> Result { + Ok(AccountBuilder { + http: Box::new(DefaultClient::try_new()?), }) } - /// Restore an existing account from the given ID, private key, server URL and HTTP client - /// - /// The key must be provided in DER-encoded PKCS#8. This is usually how ECDSA keys are - /// encoded in PEM files. Use a crate like rustls-pemfile to decode from PEM to DER. - pub async fn from_parts( - id: String, - key_pkcs8_der: &[u8], - server_url: String, - http: Box, - ) -> Result { - Ok(Self { - inner: Arc::new(AccountInner { - id, - key: Key::from_pkcs8_der(key_pkcs8_der)?, - client: Arc::new(Client::new(server_url, http).await?), - }), - }) - } - - /// Create a new account on the `server_url` with the information in [`NewAccount`] - /// - /// The returned [`AccountCredentials`] can be serialized and stored for later use. - /// Use [`Account::from_credentials()`] to restore the account from the credentials. - #[cfg(feature = "hyper-rustls")] - pub async fn create( - account: &NewAccount<'_>, - server_url: String, - external_account: Option<&ExternalAccountKey>, - ) -> Result<(Account, AccountCredentials), Error> { - Self::create_inner( - account, - external_account, - Client::new(server_url, Box::new(DefaultClient::try_new()?)).await?, - ) - .await - } - - /// Create a new account with a custom HTTP client - /// - /// The returned [`AccountCredentials`] can be serialized and stored for later use. - /// Use [`Account::from_credentials()`] to restore the account from the credentials. - pub async fn create_with_http( - account: &NewAccount<'_>, - server_url: String, - external_account: Option<&ExternalAccountKey>, - http: Box, - ) -> Result<(Account, AccountCredentials), Error> { - Self::create_inner( - account, - external_account, - Client::new(server_url, http).await?, - ) - .await - } - - async fn create_inner( - account: &NewAccount<'_>, - external_account: Option<&ExternalAccountKey>, - client: Client, - ) -> Result<(Account, AccountCredentials), Error> { - let (key, key_pkcs8) = Key::generate()?; - let payload = NewAccountPayload { - new_account: account, - external_account_binding: external_account - .map(|eak| { - JoseJson::new( - Some(&Jwk::new(&key.inner)), - eak.header(None, &client.directory.new_account), - eak, - ) - }) - .transpose()?, - }; - - let rsp = client - .post(Some(&payload), None, &key, &client.directory.new_account) - .await?; - - let account_url = rsp - .parts - .headers - .get(LOCATION) - .and_then(|hv| hv.to_str().ok()) - .map(|s| s.to_owned()); - - // The response redirects, we don't need the body - let _ = Problem::from_response(rsp).await?; - let id = account_url.ok_or("failed to get account URL")?; - let credentials = AccountCredentials { - id: id.clone(), - key_pkcs8: key_pkcs8.as_ref().to_vec(), - directory: Some(client.server_url.clone().unwrap()), // New clients always have `server_url` - // We support deserializing URLs for compatibility with versions pre 0.4, - // but we prefer to get fresh URLs from the `server_url` for newer credentials. - urls: None, - }; - - let account = AccountInner { - client: Arc::new(client), - key, - id: id.clone(), - }; - - Ok(( - Self { - inner: Arc::new(account), - }, - credentials, - )) + /// Create an account builder with the given HTTP client + pub fn builder_with_http(http: Box) -> AccountBuilder { + AccountBuilder { http } } /// Create a new order based on the given [`NewOrder`] @@ -480,6 +359,120 @@ impl Signer for AccountInner { } } +/// Builder for `Account` values +/// +/// Create one via [`Account::builder()`] or [`Account::builder_with_http()`]. +pub struct AccountBuilder { + http: Box, +} + +impl AccountBuilder { + /// Restore an existing account from the given credentials + /// + /// The [`AccountCredentials`] type is opaque, but supports deserialization. + #[allow(clippy::wrong_self_convention)] + pub async fn from_credentials(self, credentials: AccountCredentials) -> Result { + Ok(Account { + inner: Arc::new(AccountInner::from_credentials(credentials, self.http).await?), + }) + } + + /// Create a new account on the `server_url` with the information in [`NewAccount`] + /// + /// The returned [`AccountCredentials`] can be serialized and stored for later use. + /// Use [`AccountBuilder::from_credentials()`] to restore the account from the credentials. + #[cfg(feature = "hyper-rustls")] + pub async fn create( + self, + account: &NewAccount<'_>, + server_url: String, + external_account: Option<&ExternalAccountKey>, + ) -> Result<(Account, AccountCredentials), Error> { + Self::create_inner( + account, + external_account, + Client::new(server_url, self.http).await?, + ) + .await + } + + /// Restore an existing account from the given ID, private key, server URL and HTTP client + /// + /// The key must be provided in DER-encoded PKCS#8. This is usually how ECDSA keys are + /// encoded in PEM files. Use a crate like rustls-pemfile to decode from PEM to DER. + #[allow(clippy::wrong_self_convention)] + pub async fn from_parts( + self, + id: String, + key_pkcs8_der: &[u8], + server_url: String, + ) -> Result { + Ok(Account { + inner: Arc::new(AccountInner { + id, + key: Key::from_pkcs8_der(key_pkcs8_der)?, + client: Arc::new(Client::new(server_url, self.http).await?), + }), + }) + } + + async fn create_inner( + account: &NewAccount<'_>, + external_account: Option<&ExternalAccountKey>, + client: Client, + ) -> Result<(Account, AccountCredentials), Error> { + let (key, key_pkcs8) = Key::generate()?; + let payload = NewAccountPayload { + new_account: account, + external_account_binding: external_account + .map(|eak| { + JoseJson::new( + Some(&Jwk::new(&key.inner)), + eak.header(None, &client.directory.new_account), + eak, + ) + }) + .transpose()?, + }; + + let rsp = client + .post(Some(&payload), None, &key, &client.directory.new_account) + .await?; + + let account_url = rsp + .parts + .headers + .get(LOCATION) + .and_then(|hv| hv.to_str().ok()) + .map(|s| s.to_owned()); + + // The response redirects, we don't need the body + let _ = Problem::from_response(rsp).await?; + let id = account_url.ok_or("failed to get account URL")?; + let credentials = AccountCredentials { + id: id.clone(), + key_pkcs8: key_pkcs8.as_ref().to_vec(), + directory: Some(client.server_url.clone().unwrap()), // New clients always have `server_url` + // We support deserializing URLs for compatibility with versions pre 0.4, + // but we prefer to get fresh URLs from the `server_url` for newer credentials. + urls: None, + }; + + let account = AccountInner { + client: Arc::new(client), + key, + id: id.clone(), + }; + + Ok(( + Account { + inner: Arc::new(account), + }, + credentials, + )) + } +} + pub(crate) struct Key { rng: crypto::SystemRandom, signing_algorithm: SigningAlgorithm, diff --git a/src/lib.rs b/src/lib.rs index bfa98b1..e22f806 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,7 @@ use serde::Serialize; mod account; use account::Key; -pub use account::{Account, ExternalAccountKey}; +pub use account::{Account, AccountBuilder, ExternalAccountKey}; mod order; pub use order::{ AuthorizationHandle, Authorizations, ChallengeHandle, Identifiers, KeyAuthorization, Order, @@ -329,14 +329,18 @@ mod tests { #[tokio::test] async fn deserialize_old_credentials() -> Result<(), Error> { const CREDENTIALS: &str = r#"{"id":"id","key_pkcs8":"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgJVWC_QzOTCS5vtsJp2IG-UDc8cdDfeoKtxSZxaznM-mhRANCAAQenCPoGgPFTdPJ7VLLKt56RxPlYT1wNXnHc54PEyBg3LxKaH0-sJkX0mL8LyPEdsfL_Oz4TxHkWLJGrXVtNhfH","urls":{"newNonce":"new-nonce","newAccount":"new-acct","newOrder":"new-order", "revokeCert": "revoke-cert"}}"#; - Account::from_credentials(serde_json::from_str::(CREDENTIALS)?).await?; + Account::builder()? + .from_credentials(serde_json::from_str::(CREDENTIALS)?) + .await?; Ok(()) } #[tokio::test] async fn deserialize_new_credentials() -> Result<(), Error> { const CREDENTIALS: &str = r#"{"id":"id","key_pkcs8":"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgJVWC_QzOTCS5vtsJp2IG-UDc8cdDfeoKtxSZxaznM-mhRANCAAQenCPoGgPFTdPJ7VLLKt56RxPlYT1wNXnHc54PEyBg3LxKaH0-sJkX0mL8LyPEdsfL_Oz4TxHkWLJGrXVtNhfH","directory":"https://acme-staging-v02.api.letsencrypt.org/directory"}"#; - Account::from_credentials(serde_json::from_str::(CREDENTIALS)?).await?; + Account::builder()? + .from_credentials(serde_json::from_str::(CREDENTIALS)?) + .await?; Ok(()) } } diff --git a/src/types.rs b/src/types.rs index 270226e..1a724dc 100644 --- a/src/types.rs +++ b/src/types.rs @@ -517,7 +517,7 @@ pub(crate) struct NewAccountPayload<'a> { /// Input data for [Account](crate::Account) creation /// -/// To be passed into [Account::create()](crate::Account::create()). +/// To be passed into [AccountBuilder::create()](crate::AccountBuilder::create()). #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct NewAccount<'a> { diff --git a/tests/pebble.rs b/tests/pebble.rs index a139d59..0f64d50 100644 --- a/tests/pebble.rs +++ b/tests/pebble.rs @@ -358,11 +358,9 @@ async fn update_key() -> Result<(), Box> { ); // Change the Pebble environment to use the new ACME account key. - env.account = instant_acme::Account::from_credentials_and_http( - new_credentials, - Box::new(env.client.clone()), - ) - .await?; + env.account = instant_acme::Account::builder_with_http(Box::new(env.client.clone())) + .from_credentials(new_credentials) + .await?; // Using the new ACME account key should not produce an error. env.account @@ -476,17 +474,17 @@ impl Environment { // Create a new `Account` with the ACME server. debug!("creating test account"); - let (account, _) = Account::create_with_http( - &NewAccount { - contact: &[], - terms_of_service_agreed: true, - only_return_existing: false, - }, - format!("https://{}/dir", &config.pebble.listen_address), - config.eab_key.as_ref(), - Box::new(client.clone()), - ) - .await?; + let (account, _) = Account::builder_with_http(Box::new(client.clone())) + .create( + &NewAccount { + contact: &[], + terms_of_service_agreed: true, + only_return_existing: false, + }, + format!("https://{}/dir", &config.pebble.listen_address), + config.eab_key.as_ref(), + ) + .await?; info!(account_id = account.id(), "created ACME account"); Ok(Self {