From cfc34010effb5915ede5f4c89e9d7ca7adc40ba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Vandra-Meyer?= Date: Mon, 17 Mar 2025 21:36:05 +0100 Subject: [PATCH 1/4] add feature to load an account by private key --- src/lib.rs | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index c771dc9..0c19a45 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -389,6 +389,8 @@ impl Deref for ChallengeHandle<'_> { /// Create an [`Account`] with [`Account::create()`] or restore it from serialized data /// by passing deserialized [`AccountCredentials`] to [`Account::from_credentials()`]. /// +/// Alternatively, you can load an account using the private key using [`Account::load()`]. +/// /// The [`Account`] type is cheap to clone. /// /// @@ -454,6 +456,7 @@ impl Account { ) -> Result<(Account, AccountCredentials), Error> { Self::create_inner( account, + Key::generate()?, external_account, Client::new(server_url, Box::new(DefaultClient::try_new()?)).await?, server_url, @@ -473,6 +476,7 @@ impl Account { ) -> Result<(Account, AccountCredentials), Error> { Self::create_inner( account, + Key::generate()?, external_account, Client::new(server_url, http).await?, server_url, @@ -480,13 +484,47 @@ impl Account { .await } + /// Load a new account by private key, with a default or custom HTTP client + /// + /// https://www.rfc-editor.org/rfc/rfc8555#section-7.3.1 + /// + /// 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 load( + private_key_pkcs8_der: &[u8], + server_url: &str, + http: Option>, + ) -> Result<(Account, AccountCredentials), Error> { + let client = match http { + Some(http) => Client::new(server_url, http).await?, + None => Client::new(server_url, Box::new(DefaultClient::try_new()?)).await?, + }; + + let key = Key::from_pkcs8_der(private_key_pkcs8_der)?; + let pkcs8 = key.to_pkcs8_der()?; + let ignored_account = NewAccount { + only_return_existing: true, + contact: &[], + terms_of_service_agreed: true, + }; + + Self::create_inner( + &ignored_account, // This field is ignored as per rfc8555 7.3.1 + (key, pkcs8), + None, // This field is ignored as per rfc8555 7.3.1 + client, + server_url, + ) + .await + } + async fn create_inner( account: &NewAccount<'_>, + (key, key_pkcs8): (Key, crypto::pkcs8::Document), external_account: Option<&ExternalAccountKey>, client: Client, server_url: &str, ) -> Result<(Account, AccountCredentials), Error> { - let (key, key_pkcs8) = Key::generate()?; let payload = NewAccountPayload { new_account: account, external_account_binding: external_account @@ -880,6 +918,10 @@ impl Key { Self::new(pkcs8_der, crypto::SystemRandom::new()) } + fn to_pkcs8_der(&self) -> Result { + Ok(self.inner.to_pkcs8v1()?) + } + fn new(pkcs8_der: &[u8], rng: crypto::SystemRandom) -> Result { let inner = crypto::p256_key_pair_from_pkcs8(pkcs8_der, &rng)?; let thumb = BASE64_URL_SAFE_NO_PAD.encode(Jwk::thumb_sha256(&inner)?); From b33086bc5833abef34dc3ceaa83dff876d53caf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Vandra-Meyer?= Date: Tue, 18 Mar 2025 09:57:09 +0100 Subject: [PATCH 2/4] fix lint --- src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0c19a45..2c8a86c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -389,7 +389,7 @@ impl Deref for ChallengeHandle<'_> { /// Create an [`Account`] with [`Account::create()`] or restore it from serialized data /// by passing deserialized [`AccountCredentials`] to [`Account::from_credentials()`]. /// -/// Alternatively, you can load an account using the private key using [`Account::load()`]. +/// Alternatively, you can load an account using the private key using [`Account::loadgit ()`]. /// /// The [`Account`] type is cheap to clone. /// @@ -511,7 +511,7 @@ impl Account { Self::create_inner( &ignored_account, // This field is ignored as per rfc8555 7.3.1 (key, pkcs8), - None, // This field is ignored as per rfc8555 7.3.1 + None, // This field is ignored as per rfc8555 7.3.1 client, server_url, ) From 72f4b76b8fa2023f66dbd7969f7d3a1f0b4b64e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Vandra-Meyer?= Date: Tue, 18 Mar 2025 10:15:57 +0100 Subject: [PATCH 3/4] address some concerns in the pr --- src/lib.rs | 85 +++++++++++++++++++++++++++++----------------------- src/types.rs | 10 +++++++ 2 files changed, 57 insertions(+), 38 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 2c8a86c..00c9319 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,7 @@ use serde::de::DeserializeOwned; use tokio::time::sleep; mod types; +use crate::types::LOAD_EXISTING_ACCOUNT; #[cfg(feature = "time")] pub use types::RenewalInfo; pub use types::{ @@ -425,6 +426,48 @@ impl Account { }) } + /// Load a new account by private key, with a default or custom HTTP client + /// + /// https://www.rfc-editor.org/rfc/rfc8555#section-7.3.1 + /// + /// the `DefaultClient::try_new()?` can be used as the http client by default. + /// 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 for_existing_account( + key: Key, + server_url: &str, + ) -> Result<(Account, AccountCredentials), Error> { + let http = Box::new(DefaultClient::try_new()?); + + Self::for_existing_account_http(key, server_url, http) + } + + /// Load a new account by private key, with a default or custom HTTP client + /// + /// https://www.rfc-editor.org/rfc/rfc8555#section-7.3.1 + /// + /// the `DefaultClient::try_new()?` can be used as the http client by default. + /// 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 for_existing_account_http( + key: Key, + server_url: &str, + http: Box, + ) -> Result<(Account, AccountCredentials), Error> { + let client = Client::new(server_url, http).await?; + let pkcs8 = key.to_pkcs8_der()?; + + Self::create_inner( + &LOAD_EXISTING_ACCOUNT, + (key, pkcs8), + None, + client, + server_url, + ) + .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 @@ -484,40 +527,6 @@ impl Account { .await } - /// Load a new account by private key, with a default or custom HTTP client - /// - /// https://www.rfc-editor.org/rfc/rfc8555#section-7.3.1 - /// - /// 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 load( - private_key_pkcs8_der: &[u8], - server_url: &str, - http: Option>, - ) -> Result<(Account, AccountCredentials), Error> { - let client = match http { - Some(http) => Client::new(server_url, http).await?, - None => Client::new(server_url, Box::new(DefaultClient::try_new()?)).await?, - }; - - let key = Key::from_pkcs8_der(private_key_pkcs8_der)?; - let pkcs8 = key.to_pkcs8_der()?; - let ignored_account = NewAccount { - only_return_existing: true, - contact: &[], - terms_of_service_agreed: true, - }; - - Self::create_inner( - &ignored_account, // This field is ignored as per rfc8555 7.3.1 - (key, pkcs8), - None, // This field is ignored as per rfc8555 7.3.1 - client, - server_url, - ) - .await - } - async fn create_inner( account: &NewAccount<'_>, (key, key_pkcs8): (Key, crypto::pkcs8::Document), @@ -907,22 +916,22 @@ struct Key { } impl Key { - fn generate() -> Result<(Self, crypto::pkcs8::Document), Error> { + pub fn generate() -> Result<(Self, crypto::pkcs8::Document), Error> { let rng = crypto::SystemRandom::new(); let pkcs8 = crypto::EcdsaKeyPair::generate_pkcs8(&crypto::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)?; Self::new(pkcs8.as_ref(), rng).map(|key| (key, pkcs8)) } - fn from_pkcs8_der(pkcs8_der: &[u8]) -> Result { + pub fn from_pkcs8_der(pkcs8_der: &[u8]) -> Result { Self::new(pkcs8_der, crypto::SystemRandom::new()) } - fn to_pkcs8_der(&self) -> Result { + pub fn to_pkcs8_der(&self) -> Result { Ok(self.inner.to_pkcs8v1()?) } - fn new(pkcs8_der: &[u8], rng: crypto::SystemRandom) -> Result { + pub fn new(pkcs8_der: &[u8], rng: crypto::SystemRandom) -> Result { let inner = crypto::p256_key_pair_from_pkcs8(pkcs8_der, &rng)?; let thumb = BASE64_URL_SAFE_NO_PAD.encode(Jwk::thumb_sha256(&inner)?); Ok(Self { diff --git a/src/types.rs b/src/types.rs index 98b55db..e27ddd2 100644 --- a/src/types.rs +++ b/src/types.rs @@ -507,6 +507,16 @@ pub struct NewAccount<'a> { pub only_return_existing: bool, } +/// This is a special value for `NewAccount` that is supplied +/// when loading an account from a private key. +/// According to rfc8555 7.3.1 this field needs to be supplied, +/// but MUST be ignored by the ACME server. +pub(crate) static LOAD_EXISTING_ACCOUNT: NewAccount = NewAccount { + only_return_existing: true, + contact: &[], + terms_of_service_agreed: true, +}; + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DirectoryUrls { From 0baa454e78624a7dfa48bcda3e19cb7342a2a089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Vandra-Meyer?= Date: Tue, 18 Mar 2025 10:53:23 +0100 Subject: [PATCH 4/4] add test --- src/lib.rs | 9 +++++++-- tests/pebble.rs | 54 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 00c9319..dbfa99b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -440,7 +440,7 @@ impl Account { ) -> Result<(Account, AccountCredentials), Error> { let http = Box::new(DefaultClient::try_new()?); - Self::for_existing_account_http(key, server_url, http) + Self::for_existing_account_http(key, server_url, http).await } /// Load a new account by private key, with a default or custom HTTP client @@ -908,7 +908,8 @@ impl fmt::Debug for Client { } } -struct Key { +/// This struct represents a key used to sign requests to the ACME server +pub struct Key { rng: crypto::SystemRandom, signing_algorithm: SigningAlgorithm, inner: crypto::EcdsaKeyPair, @@ -916,6 +917,7 @@ struct Key { } impl Key { + /// Generate a random key pub fn generate() -> Result<(Self, crypto::pkcs8::Document), Error> { let rng = crypto::SystemRandom::new(); let pkcs8 = @@ -923,14 +925,17 @@ impl Key { Self::new(pkcs8.as_ref(), rng).map(|key| (key, pkcs8)) } + /// Create a key from a der encoded ES256 pub fn from_pkcs8_der(pkcs8_der: &[u8]) -> Result { Self::new(pkcs8_der, crypto::SystemRandom::new()) } + /// Export a key to a der encoded ES256 key pub fn to_pkcs8_der(&self) -> Result { Ok(self.inner.to_pkcs8v1()?) } + /// Create a key from a der encoded ES256 key and a crypto::SystemRandom pub fn new(pkcs8_der: &[u8], rng: crypto::SystemRandom) -> Result { let inner = crypto::p256_key_pair_from_pkcs8(pkcs8_der, &rng)?; let thumb = BASE64_URL_SAFE_NO_PAD.encode(Jwk::thumb_sha256(&inner)?); diff --git a/tests/pebble.rs b/tests/pebble.rs index c9553be..69bf82a 100644 --- a/tests/pebble.rs +++ b/tests/pebble.rs @@ -286,6 +286,60 @@ async fn account_deactivate() -> Result<(), Box> { Ok(()) } +/// Test account loading by private key +#[tokio::test] +#[ignore] +async fn account_from_private_key() -> Result<(), Box> { + use serde_json::Value; + use instant_acme::Key; + + try_tracing_init(); + + // Creat an env/initial account + let env = Environment::new(EnvironmentConfig::default()).await?; + + let new_account = NewAccount { + contact: &[], + terms_of_service_agreed: true, + only_return_existing: false, + }; + + let directory_url = format!("https://{}/dir", &env.config.pebble.listen_address); + + let (account, credentials) = Account::create(&new_account, &directory_url, None) + .await + .expect("failed to create account"); + + let json: Value = serde_json::to_value(&credentials).expect("failed to serialize credentials"); + let pkey_encoded = json.as_object().expect("a json object").get("key_pkcs8").expect("a field key_pkcs8").as_str().expect("a string encoded value"); + let pkey_der = BASE64_URL_SAFE_NO_PAD.decode(pkey_encoded).expect("a base64 encoded value"); + let key = Key::from_pkcs8_der(&pkey_der).expect("an ES256 key"); + + let (account2, credentials2) = Account::for_existing_account(key, &directory_url).await?; + + assert_eq!(account.id(), account2.id()); + assert_eq!(serde_json::to_string(&credentials).expect("a serializable value"), serde_json::to_string(&credentials2).expect("a serializable value")); + + // Creat a new env/initial account + drop(env); + let env = Environment::new(EnvironmentConfig::default()).await?; + let directory_url = format!("https://{}/dir", &env.config.pebble.listen_address); + + let key = Key::from_pkcs8_der(&pkey_der).expect("an ES256 key"); + let err = Account::for_existing_account(key, &directory_url).await.err().expect("account loading should fail for a non-existing account"); + + let Error::Api(problem) = err else { + panic!("unexpected error result {:?}", err); + }; + + assert_eq!( + problem.r#type, + Some("urn:ietf:params:acme:error:accountDoesNotExist".to_string()) + ); + + Ok(()) +} + fn try_tracing_init() { let _ = tracing_subscriber::registry() .with(fmt::layer())