diff --git a/src/lib.rs b/src/lib.rs index c771dc9..dbfa99b 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::{ @@ -389,6 +390,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::loadgit ()`]. +/// /// The [`Account`] type is cheap to clone. /// /// @@ -423,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).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 `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 @@ -454,6 +499,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 +519,7 @@ impl Account { ) -> Result<(Account, AccountCredentials), Error> { Self::create_inner( account, + Key::generate()?, external_account, Client::new(server_url, http).await?, server_url, @@ -482,11 +529,11 @@ impl Account { 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 @@ -861,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, @@ -869,18 +917,26 @@ struct Key { } impl Key { - fn generate() -> Result<(Self, crypto::pkcs8::Document), Error> { + /// Generate a random key + 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 { + /// Create a key from a der encoded ES256 + pub fn from_pkcs8_der(pkcs8_der: &[u8]) -> Result { Self::new(pkcs8_der, crypto::SystemRandom::new()) } - fn new(pkcs8_der: &[u8], rng: crypto::SystemRandom) -> Result { + /// 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)?); 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 { 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())