diff --git a/README.md b/README.md index 5cd5628..e490442 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ specification. * Support for certificate revocation * Support for the [ACME renewal information (ARI)] extension * Support for the [profiles] extension +* Support for account key rollover +* Support for account contacts update * 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 diff --git a/src/lib.rs b/src/lib.rs index 225ba70..d20a532 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -446,8 +446,13 @@ impl ChallengeHandle<'_> { .await?; *self.nonce = nonce_from_response(&rsp); - let _ = Problem::check::(rsp).await?; - Ok(()) + + let response = Problem::check::(rsp).await?; + + match response.error { + Some(details) => Err(Error::Api(details)), + None => Ok(()), + } } /// Create a [`KeyAuthorization`] for this challenge @@ -786,6 +791,91 @@ impl Account { pub fn id(&self) -> &str { &self.inner.id } + + /// Account key rollover + /// + /// This is useful if you want to change the ACME account key of an existing account, e.g. + /// to mitigate the risk of a key compromise. This method creates a new client key and changes + /// the key associated with the existing account. In case the key rollover succeeds the new + /// account credentials are returned for further usage. After that a new Account object with + /// the updated client key needs to be crated for further interaction with the ACME account. + /// + /// See for more information. + pub async fn change_key(&self, server_url: &str) -> Result { + #[derive(Debug, Serialize)] + struct NewKey<'a> { + account: &'a str, + #[serde(rename = "oldKey")] + old_key: Jwk, + } + + let new_key_url = match self.inner.client.directory.key_change.as_deref() { + Some(url) => url, + None => return Err("Account key rollover not supported by ACME CA".into()), + }; + + let (new_key, new_key_pkcs8) = Key::generate()?; + + let jwk_old = Jwk::new(&self.inner.key.inner); + + let payload_inner = NewKey { + account: &self.inner.id, + old_key: jwk_old, + }; + + let mut inner_header = new_key.header(Some("nonce"), new_key_url); + inner_header.nonce = None; + + let inner_body = JoseJson::new(Some(&payload_inner), inner_header, &new_key)?; + + let rsp = self + .inner + .post(Some(&inner_body), None, new_key_url) + .await?; + + let _ = Problem::from_response(rsp).await?; + + let credentials = AccountCredentials { + id: self.inner.id.clone(), + key_pkcs8: new_key_pkcs8.as_ref().to_vec(), + directory: Some(server_url.to_owned()), + urls: None, + }; + + Ok(credentials) + } + + /// Updates the account contacts + /// + /// This is useful if you want to update the contact information of an existing account + /// on the ACME server. The contacts argument replaces existing contacts on + /// the server. By providing an empty array the contacts are removed from the server. + /// + /// See for more information. + pub async fn update_contacts<'a>(&self, contacts: &'a [&'a str]) -> Result<(), Error> { + #[derive(Clone, Debug, Serialize)] + struct Contacts<'a> { + contact: &'a [&'a str], + } + + let payload = Contacts { contact: contacts }; + + let rsp = self + .inner + .post(Some(&payload), None, &self.inner.id) + .await?; + + #[derive(Clone, Debug, serde::Deserialize)] + struct Account { + status: AuthorizationStatus, + } + + let response = Problem::check::(rsp).await?; + match response.status { + AuthorizationStatus::Valid => Ok(()), + _ => Err("Unexpected account status after updating contact information".into()), + } + } } struct AccountInner { diff --git a/tests/pebble.rs b/tests/pebble.rs index d4ab086..1e0e9f6 100644 --- a/tests/pebble.rs +++ b/tests/pebble.rs @@ -307,12 +307,73 @@ async fn account_deactivate() -> Result<(), Box> { }; assert_eq!( - problem.r#type, - Some("urn:ietf:params:acme:error:unauthorized".to_string()) + problem.r#type.as_deref(), + Some("urn:ietf:params:acme:error:unauthorized") ); Ok(()) } +#[tokio::test] +#[ignore] +async fn update_contacts() -> Result<(), Box> { + try_tracing_init(); + + // Creat an env/initial account + let env = Environment::new(EnvironmentConfig::default()).await?; + + // Provide empty contacts information, this is fine for pebble + env.account.update_contacts(&[]).await?; + + // Provide an email address as contacts information + env.account + .update_contacts(&["mailto:alice@example.com"]) + .await?; + + Ok(()) +} + +#[tokio::test] +#[ignore] +async fn change_key() -> Result<(), Box> { + try_tracing_init(); + + // Creat an env/initial account + let mut env = Environment::new(EnvironmentConfig::default()).await?; + + let dir = &format!("https://{}/dir", &env.config.pebble.listen_address); + + // Change the account key + let new_credentials = env.account.change_key(dir).await?; + + // Using the old ACME account key should now produce malformed error. + let Err(Error::Api(problem)) = env + .account + .update_contacts(&["mailto:bob@example.com"]) + .await + else { + panic!("unexpected error result"); + }; + + assert_eq!( + problem.r#type.as_deref(), + Some("urn:ietf:params:acme:error:malformed") + ); + + // 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?; + + // Using the new ACME account key should not produce an error. + env.account + .update_contacts(&["mailto:bob@example.com"]) + .await?; + + Ok(()) +} + fn try_tracing_init() { let _ = tracing_subscriber::registry() .with(fmt::layer())