papaleguas 0.0.9

ACME client
Documentation
use std::sync::Arc;

use api::DirectoryUrl;
use bytes::Bytes;

use serde_json::{json, Value};
use tokio::sync::Mutex;

use self::api::Directory;
use self::utils::base64;

pub use account::*;
pub use authorization::*;
pub use error::*;
pub use key::*;
pub use order::*;

mod account;
mod api;
mod authorization;
mod error;
mod key;
mod order;
mod utils;

#[derive(Debug, Clone)]
struct AcmeRequest<'a> {
    url: &'a str,
    kid: Option<&'a str>,
    private_key: &'a key::PrivateKey,
    payload: Option<Value>,
}

#[derive(Debug, Clone)]
pub struct AcmeClient {
    directory_url: DirectoryUrl,
    inner: Arc<AcmeClientInner>,
}

#[derive(Debug)]
pub(crate) struct AcmeClientInner {
    http: reqwest::Client,
    directory: api::Directory,
    next_nonce: Mutex<Option<String>>,
}

#[derive(Debug, Clone, Default)]
pub struct AcmeClientBuilder {
    http_client: reqwest::Client,
}

pub const LETS_ENCRYPT_STAGING: &str = "https://acme-staging-v02.api.letsencrypt.org/directory";
pub const LETS_ENCRYPT_PRODUCTION: &str = "https://acme-v02.api.letsencrypt.org/directory";

impl AcmeClientBuilder {
    pub fn http_client(self, http_client: reqwest::Client) -> Self {
        Self { http_client }
    }

    pub async fn build_lets_encrypt_staging(self) -> AcmeResult<AcmeClient> {
        self.build_with_directory_url(LETS_ENCRYPT_STAGING).await
    }

    pub async fn build_lets_encrypt_production(self) -> AcmeResult<AcmeClient> {
        self.build_with_directory_url(LETS_ENCRYPT_PRODUCTION).await
    }

    pub async fn build_with_directory_url(
        self,
        url: impl Into<DirectoryUrl>,
    ) -> AcmeResult<AcmeClient> {
        let url = url.into();
        let directory = self.http_client.get(&url.0).send().await?;
        let directory = directory.json::<Directory>().await?;
        Ok(AcmeClient::new(url, directory, self.http_client))
    }
}

impl AcmeClientInner {
    async fn send_request(
        &self,
        request: impl Into<AcmeRequest<'_>>,
    ) -> AcmeResult<http::Response<Bytes>> {
        let request = request.into();

        let res = loop {
            let jwk = if request.kid.is_none() {
                Some(request.private_key.jwk()?)
            } else {
                None
            };

            let protected = base64(
                json!({
                    "alg": request.private_key.alg(),
                    "url": request.url,
                    "nonce": self.nonce().await?,
                    "kid": request.kid,
                    "jwk": jwk,
                })
                .to_string(),
            );

            let payload = base64(
                request
                    .payload
                    .as_ref()
                    .map(|p| p.to_string())
                    .unwrap_or_default(),
            );

            let signature = request
                .private_key
                .sign(format!("{protected}.{payload}").as_bytes())?;

            let body = json!({
                "protected": protected,
                "payload": payload,
                "signature": signature,
            });

            let mut nonce = self.next_nonce.lock().await;

            let res = self
                .http
                .post(request.url)
                .body(body.to_string())
                .header("Content-Type", "application/jose+json")
                .send()
                .await?;

            if let Some(next_nonce) = res
                .headers()
                .get("replay-nonce")
                .and_then(|nonce| nonce.to_str().ok())
            {
                nonce.replace(next_nonce.to_owned());
                drop(nonce);
            };

            if res.status().is_client_error() || res.status().is_server_error() {
                let error = res.json::<api::ServerError>().await?;

                if !error.is_bad_nonce() {
                    return Err(error.into());
                };
            } else {
                break res;
            }
        };

        let mut http_res = http::Response::builder()
            .status(res.status())
            .version(res.version());
        *http_res.headers_mut().unwrap() = res.headers().clone();

        Ok(http_res.body(res.bytes().await?).unwrap())
    }

    async fn nonce(&self) -> AcmeResult<String> {
        match self.next_nonce.lock().await.take() {
            Some(nonce) => Ok(nonce),
            None => self
                .http
                .get(&self.directory.new_nonce)
                .send()
                .await?
                .headers()
                .get("replay-nonce")
                .and_then(|nonce| nonce.to_str().ok())
                .map(|nonce| nonce.to_string())
                .ok_or_else(|| "Failed to generate nonce".into()),
        }
    }
}

impl AcmeClient {
    pub fn builder() -> AcmeClientBuilder {
        AcmeClientBuilder::default()
    }

    pub async fn from_directory_url(url: impl AsRef<str>) -> AcmeResult<AcmeClient> {
        let http = reqwest::Client::default();
        let directory = http.get(url.as_ref()).send().await?;
        let directory = directory.json::<Directory>().await?;
        Ok(Self::new(url.as_ref().into(), directory, http))
    }

    fn new(directory_url: DirectoryUrl, directory: Directory, http: reqwest::Client) -> Self {
        AcmeClient {
            directory_url,
            inner: Arc::new(AcmeClientInner {
                directory,
                http,
                next_nonce: Mutex::new(None),
            }),
        }
    }

    pub fn directory_url(&self) -> &str {
        self.directory_url.0.as_str()
    }

    pub fn directory(&self) -> &Directory {
        &self.inner.directory
    }

    pub fn new_account(&self) -> NewAccountRequest {
        NewAccountRequest::new(self.inner.clone(), &self.directory().new_account)
    }

    pub async fn existing_account_from_private_key(
        &self,
        private_key: impl TryInto<key::PrivateKey>,
    ) -> AcmeResult<Account> {
        self.new_account()
            .private_key(private_key)
            .only_return_existing(true)
            .send()
            .await
    }
}