Repository: djc/instant-acme
Pull request #100: Acme account features
Author: login: christianhoelzl
Created: 2025-04-08T08:45:28Z | Merged: 2025-04-16T14:22:00Z

This pull request adds account features and one fix to the instant-acme client:

- A new feature is added to update the contact information of an existing acme account
- A new feature is added to perform an account key rollover of an existing acme account

The contact information that is part of the acme account is according to RFC 8555, section 7.3.2. The contact update payload includes a the contact information as for example an email address.

The account key rollover is according to RFC 8555, section 7.3.5. New credentials are created by the instant-acme client and updated on the acme server. The new credentials are returned to the caller for further usage.



Diff:
diff --git a/README.md b/README.md
index 5cd56280..e490442a 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 225ba706..d20a532c 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::<Challenge>(rsp).await?;
-        Ok(())
+
+        let response = Problem::check::<Challenge>(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 <https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.5> for more information.
+    pub async fn change_key(&self, server_url: &str) -> Result<AccountCredentials, Error> {
+        #[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 <https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.2> 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::<Account>(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 d4ab0866..1e0e9f68 100644
--- a/tests/pebble.rs
+++ b/tests/pebble.rs
@@ -307,12 +307,73 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     };
 
     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<dyn StdError>> {
+    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<dyn StdError>> {
+    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())


Reviews:
user: login: djc
body: Thanks for working on this! I think we'll want some tests for this in the `pebble` integration test. Please squash any fixup commits.

Would be helpful to add links to the specific RFC sections in the docstring/comments.
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-08T09:11:08Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:31:35Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:32:12Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:33:29Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:33:52Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:34:25Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:34:53Z
user: login: djc
body: Thanks! A few more small issues.
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T08:56:04Z
user: login: cpu
body: Cool :-)
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T16:01:10Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T12:48:31Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T12:48:59Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T12:49:23Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T12:49:34Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T12:50:30Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T12:50:38Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T12:50:44Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T12:52:48Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T12:54:16Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T12:56:12Z
user: login: cpu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-13T12:57:38Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T13:11:06Z
user: login: djc
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-13T13:36:28Z
user: login: cpu
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-13T13:36:33Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T06:43:09Z
user: login: djc
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-14T18:56:40Z
user: login: cpu
body: Thanks for working through all the review feedback. Looks great.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-14T19:59:55Z
user: login: christianhoelzl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T06:00:38Z

Review comments:
diff_hunk: @@ -786,6 +792,58 @@ impl Account {
     pub fn id(&self) -> &str {
         &self.inner.id
     }
+
+    /// Account key rollover
+    pub async fn key_change(&self, server_url: &str) -> Result<AccountCredentials, Error> {
+        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 = NewKeyPayload {
+            account: self.inner.id.clone(),
+            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 settings
+    pub async fn update_contact<'a>(&self, contact: &'a [&'a str]) -> Result<(), Error> {
path: src/lib.rs
user: login: djc
body: Let's rename this to `update_contacts()`, and rename the argument to `contacts`. Can you add some additional detail in a second paragraph in the docstring to clarify whether this extends or replaces the existing contacts?
created_at: 2025-04-08T09:06:01Z
author_association: OWNER
diff_hunk: @@ -786,6 +792,58 @@ impl Account {
     pub fn id(&self) -> &str {
         &self.inner.id
     }
+
+    /// Account key rollover
+    pub async fn key_change(&self, server_url: &str) -> Result<AccountCredentials, Error> {
+        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 = NewKeyPayload {
+            account: self.inner.id.clone(),
+            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 settings
+    pub async fn update_contact<'a>(&self, contact: &'a [&'a str]) -> Result<(), Error> {
+        let payload = AccountContactUpdatePayload { contact };
path: src/lib.rs
user: login: djc
body: Let's inline the type definition for `AccountContactUpdatePayload` here, and just call it `Contacts`.
created_at: 2025-04-08T09:06:20Z
author_association: OWNER
diff_hunk: @@ -786,6 +792,58 @@ impl Account {
     pub fn id(&self) -> &str {
         &self.inner.id
     }
+
+    /// Account key rollover
+    pub async fn key_change(&self, server_url: &str) -> Result<AccountCredentials, Error> {
+        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 = NewKeyPayload {
+            account: self.inner.id.clone(),
+            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 settings
+    pub async fn update_contact<'a>(&self, contact: &'a [&'a str]) -> Result<(), Error> {
+        let payload = AccountContactUpdatePayload { contact };
+
+        let rsp = self
+            .inner
+            .post(Some(&payload), None, &self.inner.id)
+            .await?;
+
+        let _ = Problem::check::<AccountPayload>(rsp).await?;
path: src/lib.rs
user: login: djc
body: Let's also inline the definition of `AccountPayload`, and just call it `Account`. Should we be checking the value of `status`? Could the value of `status` be an enum rather than a `String`?
created_at: 2025-04-08T09:07:20Z
author_association: OWNER
diff_hunk: @@ -786,6 +792,58 @@ impl Account {
     pub fn id(&self) -> &str {
         &self.inner.id
     }
+
+    /// Account key rollover
+    pub async fn key_change(&self, server_url: &str) -> Result<AccountCredentials, Error> {
path: src/lib.rs
user: login: djc
body: Let's call this `change_key()`.
created_at: 2025-04-08T09:08:36Z
author_association: OWNER
diff_hunk: @@ -786,6 +792,58 @@ impl Account {
     pub fn id(&self) -> &str {
         &self.inner.id
     }
+
+    /// Account key rollover
+    pub async fn key_change(&self, server_url: &str) -> Result<AccountCredentials, Error> {
+        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 = NewKeyPayload {
path: src/lib.rs
user: login: djc
body: Let's inline the `NewKeyPayload` type definition here, and drop the `Payload` suffix. It looks like `account` could be a `&str` instead of `String`?
created_at: 2025-04-08T09:09:07Z
author_association: OWNER
diff_hunk: @@ -446,8 +446,14 @@ impl ChallengeHandle<'_> {
             .await?;
 
         *self.nonce = nonce_from_response(&rsp);
-        let _ = Problem::check::<Challenge>(rsp).await?;
-        Ok(())
+
+        let response_payload = Problem::check::<Challenge>(rsp).await?;
+
+        if let Some(problem_details) = response_payload.error {
+            Err(Error::Api(problem_details))
+        } else {
+            Ok(())
+        }
path: src/lib.rs
user: login: djc
body: Nit, drop the `_payload` suffix, the `problem_` prefix and rewrite it like this:

```rust
match payload.error {
    Some(details) => Err(Error::Api(details)),
    None => Ok(()),
}
```
created_at: 2025-04-08T09:10:37Z
author_association: OWNER
diff_hunk: @@ -786,6 +792,58 @@ impl Account {
     pub fn id(&self) -> &str {
         &self.inner.id
     }
+
+    /// Account key rollover
+    pub async fn key_change(&self, server_url: &str) -> Result<AccountCredentials, Error> {
+        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 = NewKeyPayload {
+            account: self.inner.id.clone(),
+            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 settings
+    pub async fn update_contact<'a>(&self, contact: &'a [&'a str]) -> Result<(), Error> {
path: src/lib.rs
user: login: christianhoelzl
body: method and arguments renamed
created_at: 2025-04-08T14:31:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -786,6 +792,58 @@ impl Account {
     pub fn id(&self) -> &str {
         &self.inner.id
     }
+
+    /// Account key rollover
+    pub async fn key_change(&self, server_url: &str) -> Result<AccountCredentials, Error> {
+        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 = NewKeyPayload {
+            account: self.inner.id.clone(),
+            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 settings
+    pub async fn update_contact<'a>(&self, contact: &'a [&'a str]) -> Result<(), Error> {
+        let payload = AccountContactUpdatePayload { contact };
path: src/lib.rs
user: login: christianhoelzl
body: The type is inline and renamed to Contacts.
created_at: 2025-04-08T14:32:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -786,6 +792,58 @@ impl Account {
     pub fn id(&self) -> &str {
         &self.inner.id
     }
+
+    /// Account key rollover
+    pub async fn key_change(&self, server_url: &str) -> Result<AccountCredentials, Error> {
+        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 = NewKeyPayload {
+            account: self.inner.id.clone(),
+            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 settings
+    pub async fn update_contact<'a>(&self, contact: &'a [&'a str]) -> Result<(), Error> {
+        let payload = AccountContactUpdatePayload { contact };
+
+        let rsp = self
+            .inner
+            .post(Some(&payload), None, &self.inner.id)
+            .await?;
+
+        let _ = Problem::check::<AccountPayload>(rsp).await?;
path: src/lib.rs
user: login: christianhoelzl
body: The type definition is inline now. The status is changed to an existing enum AuthorizationStatus and checked. Only Valid is accepted.
created_at: 2025-04-08T14:33:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -786,6 +792,58 @@ impl Account {
     pub fn id(&self) -> &str {
         &self.inner.id
     }
+
+    /// Account key rollover
+    pub async fn key_change(&self, server_url: &str) -> Result<AccountCredentials, Error> {
path: src/lib.rs
user: login: christianhoelzl
body: Method name changed.
created_at: 2025-04-08T14:33:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -786,6 +792,58 @@ impl Account {
     pub fn id(&self) -> &str {
         &self.inner.id
     }
+
+    /// Account key rollover
+    pub async fn key_change(&self, server_url: &str) -> Result<AccountCredentials, Error> {
+        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 = NewKeyPayload {
path: src/lib.rs
user: login: christianhoelzl
body: Changed, after inlining there are no changes to types.rs anymore.
created_at: 2025-04-08T14:34:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -446,8 +446,14 @@ impl ChallengeHandle<'_> {
             .await?;
 
         *self.nonce = nonce_from_response(&rsp);
-        let _ = Problem::check::<Challenge>(rsp).await?;
-        Ok(())
+
+        let response_payload = Problem::check::<Challenge>(rsp).await?;
+
+        if let Some(problem_details) = response_payload.error {
+            Err(Error::Api(problem_details))
+        } else {
+            Ok(())
+        }
path: src/lib.rs
user: login: christianhoelzl
body: changed as proposed
created_at: 2025-04-08T14:34:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -446,8 +446,13 @@ impl ChallengeHandle<'_> {
             .await?;
 
         *self.nonce = nonce_from_response(&rsp);
-        let _ = Problem::check::<Challenge>(rsp).await?;
-        Ok(())
+
+        let response = Problem::check::<Challenge>(rsp).await?;
+
+        match response.error {
+            Some(details) => Err(Error::Api(details)),
+            None => Ok(()),
+        }
path: src/lib.rs
user: login: djc
body: This change seems unrelated and introduces consistency issues. If you think this is an improvement, let's do this inside `Problem::from_response()`?
created_at: 2025-04-11T08:52:00Z
author_association: OWNER
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
path: tests/pebble.rs
user: login: djc
body: Nit: this `assert_eq!()` is pointless.
created_at: 2025-04-11T08:53:52Z
author_association: OWNER
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
+    let Err(Error::Api(problem)) = env.account.update_contacts(&["alice@example.com"]).await else {
+        panic!("unexpected result");
+    };
+
+    assert_eq!(
+        problem.r#type,
path: tests/pebble.rs
user: login: djc
body: Suggest doing `.as_ref()` on `problem.r#type` instead of `to_string()` on the literal.
created_at: 2025-04-11T08:54:54Z
author_association: OWNER
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
+    let Err(Error::Api(problem)) = env.account.update_contacts(&["alice@example.com"]).await else {
+        panic!("unexpected result");
+    };
+
+    assert_eq!(
+        problem.r#type,
+        Some("urn:ietf:params:acme:error:unsupportedContact".to_string())
+    );
+    Ok(())
+}
+
+#[tokio::test]
+#[ignore]
+async fn change_key() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let mut env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Change the account key
+    let result = env
+        .account
+        .clone()
path: tests/pebble.rs
user: login: djc
body: Nit: no need to `clone()` here, I think?
created_at: 2025-04-11T08:55:25Z
author_association: OWNER
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
+    let Err(Error::Api(problem)) = env.account.update_contacts(&["alice@example.com"]).await else {
+        panic!("unexpected result");
+    };
+
+    assert_eq!(
+        problem.r#type,
+        Some("urn:ietf:params:acme:error:unsupportedContact".to_string())
+    );
+    Ok(())
+}
+
+#[tokio::test]
+#[ignore]
+async fn change_key() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let mut env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Change the account key
+    let result = env
+        .account
+        .clone()
+        .change_key("https://[::1]:14000/dir")
+        .await;
+    assert!(result.is_ok());
path: tests/pebble.rs
user: login: djc
body: Instead of this `assert!()`, just tack a `?` onto the `.await` above.
created_at: 2025-04-11T08:55:54Z
author_association: OWNER
diff_hunk: @@ -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 client key of an existing account, e.g.
path: src/lib.rs
user: login: cpu
body: nit: maybe "ACME account key" instead of "acme client key"? ACME is an acronym and should be capitalized and I think its best to stick to the "account" terminology.
created_at: 2025-04-11T15:54:19Z
author_association: COLLABORATOR
diff_hunk: @@ -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 client key of an existing account, e.g.
+    /// to mitigate the risk of a key compromise. This method crates 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.
path: src/lib.rs
user: login: cpu
body: typo here: "crated" -> "created"

(and same feedback RE: "acme" as above).
created_at: 2025-04-11T15:54:37Z
author_association: COLLABORATOR
diff_hunk: @@ -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 client key of an existing account, e.g.
+    /// to mitigate the risk of a key compromise. This method crates 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 <https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.5> for more information.
+    pub async fn change_key(&self, server_url: &str) -> Result<AccountCredentials, Error> {
+        #[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
path: src/lib.rs
user: login: cpu
body: same nit here: "acme" -> ACME
created_at: 2025-04-11T15:55:57Z
author_association: COLLABORATOR
diff_hunk: @@ -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 client key of an existing account, e.g.
+    /// to mitigate the risk of a key compromise. This method crates 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 <https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.5> for more information.
+    pub async fn change_key(&self, server_url: &str) -> Result<AccountCredentials, Error> {
+        #[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 <https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.2> 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::<Account>(rsp).await?;
+        match response.status {
+            AuthorizationStatus::Valid => Ok(()),
+            _ => Err("Unexpected account status after update contacts information".into()),
path: src/lib.rs
user: login: cpu
body: nit: I think the grammar of this message might be improved by phrasing it "Unexpected account status after updating contact information". WDYT?
created_at: 2025-04-11T15:56:28Z
author_association: COLLABORATOR
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
path: tests/pebble.rs
user: login: cpu
body: Hmmm, it's been a while, and I only took a quick peek, but it looks to me like it does support this: https://github.com/letsencrypt/pebble/blob/dc9bf9d200b49f5b587258ec53948f17417a7579/wfe/wfe.go#L1028-L1121

Can you share the error you're getting?
created_at: 2025-04-11T15:58:11Z
author_association: COLLABORATOR
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
+    let Err(Error::Api(problem)) = env.account.update_contacts(&["alice@example.com"]).await else {
+        panic!("unexpected result");
+    };
+
+    assert_eq!(
+        problem.r#type,
+        Some("urn:ietf:params:acme:error:unsupportedContact".to_string())
+    );
+    Ok(())
+}
+
+#[tokio::test]
+#[ignore]
+async fn change_key() -> Result<(), Box<dyn StdError>> {
path: tests/pebble.rs
user: login: cpu
body: Can we make this test a little bit more robust? I think it would be nice to:

1. Verify the old account key no longer works after the rollover.
2. Verify the new account key does work after the rollover.
created_at: 2025-04-11T15:59:21Z
author_association: COLLABORATOR
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
path: tests/pebble.rs
user: login: christianhoelzl
body: This pointless assert is removed.
created_at: 2025-04-13T12:48:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
+    let Err(Error::Api(problem)) = env.account.update_contacts(&["alice@example.com"]).await else {
+        panic!("unexpected result");
+    };
+
+    assert_eq!(
+        problem.r#type,
path: tests/pebble.rs
user: login: christianhoelzl
body: done
created_at: 2025-04-13T12:48:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
+    let Err(Error::Api(problem)) = env.account.update_contacts(&["alice@example.com"]).await else {
+        panic!("unexpected result");
+    };
+
+    assert_eq!(
+        problem.r#type,
+        Some("urn:ietf:params:acme:error:unsupportedContact".to_string())
+    );
+    Ok(())
+}
+
+#[tokio::test]
+#[ignore]
+async fn change_key() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let mut env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Change the account key
+    let result = env
+        .account
+        .clone()
path: tests/pebble.rs
user: login: christianhoelzl
body: The clone() is removed
created_at: 2025-04-13T12:49:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
+    let Err(Error::Api(problem)) = env.account.update_contacts(&["alice@example.com"]).await else {
+        panic!("unexpected result");
+    };
+
+    assert_eq!(
+        problem.r#type,
+        Some("urn:ietf:params:acme:error:unsupportedContact".to_string())
+    );
+    Ok(())
+}
+
+#[tokio::test]
+#[ignore]
+async fn change_key() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let mut env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Change the account key
+    let result = env
+        .account
+        .clone()
+        .change_key("https://[::1]:14000/dir")
+        .await;
+    assert!(result.is_ok());
path: tests/pebble.rs
user: login: christianhoelzl
body: done
created_at: 2025-04-13T12:49:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 client key of an existing account, e.g.
path: src/lib.rs
user: login: christianhoelzl
body: Changed to uppercase ACME and account key.
created_at: 2025-04-13T12:50:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 client key of an existing account, e.g.
+    /// to mitigate the risk of a key compromise. This method crates 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.
path: src/lib.rs
user: login: christianhoelzl
body: fixed
created_at: 2025-04-13T12:50:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 client key of an existing account, e.g.
+    /// to mitigate the risk of a key compromise. This method crates 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 <https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.5> for more information.
+    pub async fn change_key(&self, server_url: &str) -> Result<AccountCredentials, Error> {
+        #[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
path: src/lib.rs
user: login: christianhoelzl
body: fixed
created_at: 2025-04-13T12:50:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 client key of an existing account, e.g.
+    /// to mitigate the risk of a key compromise. This method crates 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 <https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.5> for more information.
+    pub async fn change_key(&self, server_url: &str) -> Result<AccountCredentials, Error> {
+        #[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 <https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.2> 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::<Account>(rsp).await?;
+        match response.status {
+            AuthorizationStatus::Valid => Ok(()),
+            _ => Err("Unexpected account status after update contacts information".into()),
path: src/lib.rs
user: login: christianhoelzl
body: I changed it, the new sentence is better.
created_at: 2025-04-13T12:52:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
path: tests/pebble.rs
user: login: christianhoelzl
body: I get a problem detail that contacts are not supported (pebble version 2.7.0 used on windows).

```
&problem = Problem {
    type: Some(
        "urn:ietf:params:acme:error:unsupportedContact",
    ),
    detail: Some(
        "contact method \"\" is not supported",
    ),
    status: Some(
        400,
    ),
    subproblems: [],
}

```
created_at: 2025-04-13T12:54:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
+    let Err(Error::Api(problem)) = env.account.update_contacts(&["alice@example.com"]).await else {
+        panic!("unexpected result");
+    };
+
+    assert_eq!(
+        problem.r#type,
+        Some("urn:ietf:params:acme:error:unsupportedContact".to_string())
+    );
+    Ok(())
+}
+
+#[tokio::test]
+#[ignore]
+async fn change_key() -> Result<(), Box<dyn StdError>> {
path: tests/pebble.rs
user: login: christianhoelzl
body: The test method is extended to have both cases tested. The first one produces a "malformed" error and the second should succeed.
created_at: 2025-04-13T12:56:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
path: tests/pebble.rs
user: login: cpu
body: Ahhh!!! I think that's because you're sending the contact `alice@example.com`. I believe you want `mailto:alice@example.com`. The contacts are **URLs** and so need a scheme, like `mailto:` or `tel:`.

See RFC 8555 7.3, servers MUST support `mailto:` at a minimum:
>  If the server validates contact URLs, it MUST support the "mailto" scheme. 
created_at: 2025-04-13T12:57:38Z
author_association: COLLABORATOR
diff_hunk: @@ -446,8 +446,13 @@ impl ChallengeHandle<'_> {
             .await?;
 
         *self.nonce = nonce_from_response(&rsp);
-        let _ = Problem::check::<Challenge>(rsp).await?;
-        Ok(())
+
+        let response = Problem::check::<Challenge>(rsp).await?;
+
+        match response.error {
+            Some(details) => Err(Error::Api(details)),
+            None => Ok(()),
+        }
path: src/lib.rs
user: login: christianhoelzl
body: This seems unrelated. On the other hand RFC8555 defines an inconsistent error handling (chapter 6.7 and 8).

1. Errors reported at the HTTP layer (this is `Problem::from_response`)
2. More specific within the Challenge Object

Adding both to `Problem::from_response` in a strict mode would require to differentiate between the Challenge object and any other response types. In the last commit I added an additional method to Problem impl for case 2 instead and used the new `Problem::check_challenge` inside `set_ready`. WDYT?
created_at: 2025-04-13T13:11:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
path: tests/pebble.rs
user: login: djc
body: I believe mailto URIs don't get the //, just the :.
created_at: 2025-04-13T13:36:28Z
author_association: OWNER
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
path: tests/pebble.rs
user: login: cpu
body: Just updated my comment above; the correct form is `mailto:alice@example.com`, not `mailto://alice@example.com`. Sorry about that :-)

Making that change the request succeeds. 
created_at: 2025-04-13T13:36:33Z
author_association: COLLABORATOR
diff_hunk: @@ -313,6 +313,49 @@ async fn account_deactivate() -> Result<(), Box<dyn StdError>> {
     Ok(())
 }
 
+#[tokio::test]
+#[ignore]
+async fn update_contacts() -> Result<(), Box<dyn StdError>> {
+    try_tracing_init();
+
+    // Creat an env/initial account
+    let env = Environment::new(EnvironmentConfig::default()).await?;
+
+    // Provide empty contacts information, this is fine for pebble
+    let result = env.account.update_contacts(&[]).await?;
+    assert_eq!(result, ());
+
+    // At the time of writing this testcase, pebble does not support contacts information.
path: tests/pebble.rs
user: login: christianhoelzl
body: Great, good catch! I fixed it in the pull request.
created_at: 2025-04-14T06:43:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -446,7 +446,11 @@ impl ChallengeHandle<'_> {
             .await?;
 
         *self.nonce = nonce_from_response(&rsp);
-        let _ = Problem::check::<Challenge>(rsp).await?;
+
+        let response = Problem::check::<Challenge>(rsp).await?;
path: src/lib.rs
user: login: djc
body: I still don't understand the motivation for this change. It also seems mostly unrelated to the two account management features you've added here, so I'd suggest you revert these changes (and the method addition supporting it) for this PR and submit a new PR for them with a more detailed motivation for why this is necessary/helps.
created_at: 2025-04-14T18:56:36Z
author_association: OWNER
diff_hunk: @@ -446,7 +446,11 @@ impl ChallengeHandle<'_> {
             .await?;
 
         *self.nonce = nonce_from_response(&rsp);
-        let _ = Problem::check::<Challenge>(rsp).await?;
+
+        let response = Problem::check::<Challenge>(rsp).await?;
path: src/lib.rs
user: login: christianhoelzl
body: Fine, I reverted the set ready change and create a second pull request for this.
created_at: 2025-04-15T06:00:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: christianhoelzl
created_at: 2025-04-08T14:43:19Z
body: Documentation of the new API methods is added to the last commit with links into RFC 8555.

You mentioned a pebble integration test. I need some guidance regarding pebble. Where should a testcase be placed in pebble.rs? Not shure if pebble supports contacts update or account key rollover.
author_association: CONTRIBUTOR
user: login: djc
created_at: 2025-04-08T14:55:51Z
body: Just add new tests below the list existing test functions. Suggest just trying out whether Pebble supports these endpoints.
author_association: OWNER
user: login: christianhoelzl
created_at: 2025-04-10T15:40:58Z
body: Two pebble tests are added to this pull request. I tested locally on windows OS with the latest pebble release.
author_association: CONTRIBUTOR
user: login: christianhoelzl
created_at: 2025-04-13T13:17:19Z
body: Most review changes are fixed in this commit. I extended the readme to show the new features.
Still open is the discussion on Problem::from_response and the discussion on the pebble test for update contacts.
author_association: CONTRIBUTOR
user: login: cpu
created_at: 2025-04-13T13:20:49Z
body: > Still open is the discussion on Problem::from_response and the discussion on the pebble test for update contacts.

I left [a comment](https://github.com/djc/instant-acme/pull/100#discussion_r2041120332) about the issue for the pebble test for contacts update. I think you just need to fix the contact you're using in the update.
author_association: COLLABORATOR
user: login: cpu
created_at: 2025-04-16T14:13:55Z
body: @djc Thinking we should squash merge this one, oui? Do you want to do the honours?
author_association: COLLABORATOR
user: login: djc
created_at: 2025-04-16T14:22:15Z
body: Fait accompli!
author_association: OWNER
user: login: christianhoelzl
created_at: 2025-04-16T14:36:29Z
body: @djc and @cpu, thank you for merging this pull request. This was my very first public pull request. For me it was fun working through your comments and contributing to instant-acme.
author_association: CONTRIBUTOR
user: login: cpu
created_at: 2025-04-16T14:39:04Z
body: > This was my very first public pull request.

That's awesome. Thanks for sharing :-) You did a great job.
author_association: COLLABORATOR
user: login: djc
created_at: 2025-04-16T21:28:44Z
body: Thanks for your contribution! Hopefully this left you feeling motivated to contribute more in the future!
author_association: OWNER