commit 0060778f15cc91b39e8ad98e499e484c28d52779 Author: Dirkjan Ochtman Date: Tue Jan 25 15:06:38 2022 +0100 rust: initial implementation of an ACME client diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1595204 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "acme" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +base64 = "0.13.0" +reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls-native-roots"] } +ring = { version = "0.16.20", features = ["std"] } +serde = { version = "1.0.104", features = ["derive"] } +serde_json = "1.0.78" +thiserror = "1.0.30" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..78a0ecb --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,369 @@ +#![warn(unreachable_pub)] + +use std::sync::Arc; + +use base64::URL_SAFE_NO_PAD; +use reqwest::header::{CONTENT_TYPE, LOCATION}; +use reqwest::redirect::Policy; +use reqwest::{Body, Response}; +use ring::rand::SystemRandom; +use ring::signature::{EcdsaKeyPair, ECDSA_P256_SHA256_FIXED_SIGNING}; +use serde::de::{DeserializeOwned, Error as _, Unexpected}; +use serde::{Deserialize, Serialize}; + +mod types; +use types::{ + AccountCredentials, Challenge, DirectoryUrls, Empty, FinalizeRequest, Header, JoseJson, Jwk, + KeyAuthorization, KeyOrKeyId, OrderState, Problem, SigningAlgorithm, +}; +pub use types::{ + Authorization, ChallengeType, Error, Identifier, LetsEncrypt, NewAccount, NewOrder, OrderStatus, +}; + +pub struct Order { + account: Arc, + nonce: Option, + #[allow(dead_code)] + order_url: String, +} + +impl Order { + pub async fn authorizations( + &mut self, + authz_urls: &[String], + ) -> Result, Error> { + let mut authorizations = Vec::with_capacity(authz_urls.len()); + for url in authz_urls { + authorizations.push(self.account.get(&mut self.nonce, url).await?); + } + Ok(authorizations) + } + + pub fn key_authorization(&self, challenge: &Challenge) -> KeyAuthorization { + KeyAuthorization(format!("{}.{}", challenge.token, &self.account.key.thumb)) + } + + pub async fn finalize( + &mut self, + csr_der: &[u8], + finalize_url: &str, + ) -> Result { + let rsp = self + .account + .post( + Some(&FinalizeRequest::new(csr_der)), + self.nonce.take(), + finalize_url, + ) + .await?; + + self.nonce = nonce_from_response(&rsp); + Problem::check(rsp).await + } + + pub async fn set_challenge_ready(&mut self, challenge_url: &str) -> Result<(), Error> { + let rsp = self + .account + .post(Some(&Empty {}), self.nonce.take(), challenge_url) + .await?; + + self.nonce = nonce_from_response(&rsp); + let _ = Problem::check::(rsp).await?; + Ok(()) + } + + pub async fn certificate_chain(&mut self, cert_url: &str) -> Result { + let rsp = self + .account + .post(None::<&Empty>, self.nonce.take(), cert_url) + .await?; + + self.nonce = nonce_from_response(&rsp); + let status = rsp.status(); + match status.is_client_error() || status.is_server_error() { + false => Ok(rsp.text().await?), + true => Err(rsp.json::().await?.into()), + } + } + + pub async fn challenge(&mut self, challenge_url: &str) -> Result { + self.account.get(&mut self.nonce, challenge_url).await + } + + pub async fn state(&mut self) -> Result { + self.account.get(&mut self.nonce, &self.order_url).await + } +} + +#[derive(Clone)] +pub struct Account { + inner: Arc, +} + +impl Account { + pub async fn create(account: &NewAccount<'_>, server_url: &str) -> Result { + let client = client()?; + let urls = client.get(server_url).send().await?; + let client = Client { + client, + urls: urls.json().await?, + }; + + let key = Key::generate()?; + let nonce = client.nonce().await?; + let header = key.key_header(&nonce, &client.urls.new_account); + let body = key.signed_json(Some(account), header)?; + + let rsp = client + .client + .post(&client.urls.new_account) + .header(CONTENT_TYPE, JOSE_JSON) + .body(body) + .send() + .await?; + + let account_url = rsp + .headers() + .get(LOCATION) + .and_then(|hv| hv.to_str().ok()) + .map(|s| s.to_owned()); + + let status = rsp.status(); + if status.is_client_error() || status.is_server_error() { + return Err(rsp.json::().await?.into()); + } + + Ok(Self { + inner: Arc::new(AccountInner { + client, + key, + id: account_url.ok_or("failed to get account URL")?, + }), + }) + } + + pub async fn new_order<'a>( + &'a self, + order: &NewOrder<'_>, + ) -> Result<(Order, OrderState), Error> { + let rsp = self + .inner + .post(Some(order), None, &self.inner.client.urls.new_order) + .await?; + + let nonce = nonce_from_response(&rsp); + let order_url = rsp + .headers() + .get(LOCATION) + .and_then(|hv| hv.to_str().ok()) + .map(|s| s.to_owned()); + + let status = Problem::check(rsp).await?; + Ok(( + Order { + account: self.inner.clone(), + nonce, + order_url: order_url.ok_or("no order URL found")?, + }, + status, + )) + } +} + +impl<'de> Deserialize<'de> for Account { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let creds = AccountCredentials::deserialize(deserializer)?; + let pkcs8_der = base64::decode_config(&creds.key_pkcs8, URL_SAFE_NO_PAD).map_err(|_| { + D::Error::invalid_value( + Unexpected::Str(&creds.key_pkcs8), + &"unable to base64-decode key", + ) + })?; + + Ok(Self { + inner: Arc::new(AccountInner { + key: Key::from_pkcs8_der(pkcs8_der).map_err(|_| { + D::Error::invalid_value( + Unexpected::Str(&creds.key_pkcs8), + &"unable to parse key", + ) + })?, + client: Client { + client: client().map_err(D::Error::custom)?, + urls: creds.urls.clone(), + }, + id: creds.id.clone(), + }), + }) + } +} + +impl Serialize for Account { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + AccountCredentials { + id: self.inner.id.clone(), + key_pkcs8: base64::encode_config(&self.inner.key.pkcs8_der, URL_SAFE_NO_PAD), + urls: self.inner.client.urls.clone(), + } + .serialize(serializer) + } +} + +struct AccountInner { + client: Client, + key: Key, + id: String, +} + +impl AccountInner { + async fn get( + &self, + nonce: &mut Option, + url: &str, + ) -> Result { + let rsp = self.post(None::<&Empty>, nonce.take(), url).await?; + *nonce = nonce_from_response(&rsp); + Ok(Problem::check(rsp).await?) + } + + async fn post( + &self, + payload: Option<&impl Serialize>, + nonce: Option, + url: &str, + ) -> Result { + let nonce = match nonce { + Some(nonce) => nonce, + None => self.client.nonce().await?, + }; + + let header = self.key_id_header(&nonce, url); + let body = self.key.signed_json(payload, header)?; + Ok(self + .client + .client + .post(url) + .header(CONTENT_TYPE, JOSE_JSON) + .body(body) + .send() + .await?) + } + + fn key_id_header<'n, 'u: 'n, 'a: 'u>(&'a self, nonce: &'n str, url: &'u str) -> Header<'n> { + Header { + alg: self.key.signing_algorithm, + key: KeyOrKeyId::KeyId(&self.id), + nonce, + url, + } + } +} + +#[derive(Debug)] +struct Client { + client: reqwest::Client, + urls: DirectoryUrls, +} + +impl Client { + async fn nonce(&self) -> Result { + let future = self.client.head(&self.urls.new_nonce).send(); + match nonce_from_response(&future.await?) { + Some(nonce) => Ok(nonce), + None => Err("no nonce found".into()), + } + } +} + +struct Key { + rng: SystemRandom, + signing_algorithm: SigningAlgorithm, + inner: EcdsaKeyPair, + pkcs8_der: Vec, + thumb: String, +} + +impl Key { + fn generate() -> Result { + let rng = SystemRandom::new(); + let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng)?; + let key = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref())?; + let thumb = base64::encode_config(Jwk::thumb_sha256(&key)?, URL_SAFE_NO_PAD); + + Ok(Self { + rng, + signing_algorithm: SigningAlgorithm::Es256, + inner: key, + pkcs8_der: pkcs8.as_ref().to_vec(), + thumb, + }) + } + + fn from_pkcs8_der(pkcs8_der: Vec) -> Result { + let key = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &pkcs8_der)?; + let thumb = base64::encode_config(Jwk::thumb_sha256(&key)?, URL_SAFE_NO_PAD); + + Ok(Self { + rng: SystemRandom::new(), + signing_algorithm: SigningAlgorithm::Es256, + inner: key, + pkcs8_der, + thumb, + }) + } + + fn signed_json( + &self, + payload: Option<&impl Serialize>, + protected: Header<'_>, + ) -> Result { + let protected = base64(&protected)?; + let payload = match payload { + Some(data) => base64(&data)?, + None => String::new(), + }; + + let combined = format!("{}.{}", protected, payload); + let signature = self.inner.sign(&self.rng, combined.as_bytes())?; + Ok(Body::from(serde_json::to_vec(&JoseJson { + protected, + payload, + signature: base64::encode_config(signature.as_ref(), URL_SAFE_NO_PAD), + })?)) + } + + fn key_header<'n, 'u: 'n, 'k: 'u>(&'k self, nonce: &'n str, url: &'u str) -> Header<'n> { + Header { + alg: self.signing_algorithm, + key: KeyOrKeyId::from_key(&self.inner), + nonce, + url, + } + } +} + +fn nonce_from_response(rsp: &Response) -> Option { + rsp.headers() + .get(REPLAY_NONCE) + .and_then(|hv| String::from_utf8(hv.as_ref().to_vec()).ok()) +} + +fn base64(data: &impl Serialize) -> Result { + Ok(base64::encode_config( + serde_json::to_vec(data)?, + URL_SAFE_NO_PAD, + )) +} + +fn client() -> Result { + reqwest::Client::builder().redirect(Policy::none()).build() +} + +const JOSE_JSON: &str = "application/jose+json"; +const REPLAY_NONCE: &str = "Replay-Nonce"; diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..10ef073 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,286 @@ +use std::fmt; + +use base64::URL_SAFE_NO_PAD; +use reqwest::Response; +use ring::digest::{digest, Digest, SHA256}; +use ring::signature::{EcdsaKeyPair, KeyPair}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("API error")] + Api(#[from] Problem), + #[error("base64 decoding failed")] + Base64(#[from] base64::DecodeError), + #[error("cryptographic operation failed")] + Crypto(#[from] ring::error::Unspecified), + #[error("invalid key bytes")] + CryptoKey(#[from] ring::error::KeyRejected), + #[error("HTTP request failure")] + Http(#[from] reqwest::Error), + #[error("failed to (de)serialize JSON")] + Json(#[from] serde_json::Error), + #[error("missing data: {0}")] + Str(&'static str), +} + +impl From<&'static str> for Error { + fn from(s: &'static str) -> Self { + Error::Str(s) + } +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct AccountCredentials { + pub(crate) id: String, + pub(crate) key_pkcs8: String, + pub(crate) urls: DirectoryUrls, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Problem { + pub r#type: String, + pub detail: String, + pub status: u16, +} + +impl Problem { + pub(crate) async fn check(rsp: Response) -> Result { + let status = rsp.status(); + match status.is_client_error() || status.is_server_error() { + false => Ok(rsp.json().await?), + true => Err(rsp.json::().await?.into()), + } + } +} + +impl fmt::Display for Problem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "API error: {} ({})", self.detail, self.r#type) + } +} + +impl std::error::Error for Problem {} + +pub struct KeyAuthorization(pub(crate) String); + +impl KeyAuthorization { + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn dns_value(&self) -> String { + base64::encode_config(digest(&SHA256, self.0.as_bytes()), URL_SAFE_NO_PAD) + } +} + +impl fmt::Debug for KeyAuthorization { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("KeyAuthorization").finish() + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct FinalizeRequest { + csr: String, +} + +impl FinalizeRequest { + pub(crate) fn new(csr_der: &[u8]) -> Self { + Self { + csr: base64::encode_config(csr_der, URL_SAFE_NO_PAD), + } + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct Header<'a> { + pub(crate) alg: SigningAlgorithm, + #[serde(flatten)] + pub(crate) key: KeyOrKeyId<'a>, + pub(crate) nonce: &'a str, + pub(crate) url: &'a str, +} + +#[derive(Debug, Serialize)] +pub(crate) enum KeyOrKeyId<'a> { + #[serde(rename = "jwk")] + Key(Jwk), + #[serde(rename = "kid")] + KeyId(&'a str), +} + +impl<'a> KeyOrKeyId<'a> { + pub(crate) fn from_key(key: &EcdsaKeyPair) -> KeyOrKeyId<'static> { + KeyOrKeyId::Key(Jwk::new(key)) + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct Jwk { + alg: SigningAlgorithm, + crv: &'static str, + kty: &'static str, + r#use: &'static str, + x: String, + y: String, +} + +impl Jwk { + pub(crate) fn new(key: &EcdsaKeyPair) -> Self { + let (x, y) = key.public_key().as_ref()[1..].split_at(32); + Self { + alg: SigningAlgorithm::Es256, + crv: "P-256", + kty: "EC", + r#use: "sig", + x: base64::encode_config(x, URL_SAFE_NO_PAD), + y: base64::encode_config(y, URL_SAFE_NO_PAD), + } + } + + pub(crate) fn thumb_sha256(key: &EcdsaKeyPair) -> Result { + let jwk = Self::new(key); + Ok(digest( + &SHA256, + &serde_json::to_vec(&JwkThumb { + crv: jwk.crv, + kty: jwk.kty, + x: &jwk.x, + y: &jwk.y, + })?, + )) + } +} + +#[derive(Debug, Serialize)] +struct JwkThumb<'a> { + crv: &'a str, + kty: &'a str, + x: &'a str, + y: &'a str, +} + +#[derive(Debug, Deserialize)] +pub struct Challenge { + pub r#type: ChallengeType, + pub url: String, + pub token: String, + pub status: ChallengeStatus, + pub error: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderState { + pub status: OrderStatus, + pub authorizations: Vec, + pub error: Option, + pub finalize: String, + pub certificate: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NewOrder<'a> { + pub identifiers: &'a [Identifier], +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NewAccount<'a> { + pub contact: &'a [&'a str], + pub terms_of_service_agreed: bool, + pub only_return_existing: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectoryUrls { + pub(crate) new_nonce: String, + pub(crate) new_account: String, + pub(crate) new_order: String, +} + +#[derive(Serialize)] +pub(crate) struct JoseJson { + pub(crate) protected: String, + pub(crate) payload: String, + pub(crate) signature: String, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum Authorization { + Pending { + identifier: Identifier, + challenges: Vec, + }, + Valid, + Invalid, + Revoked, + Expired, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type", content = "value", rename_all = "camelCase")] +pub enum Identifier { + Dns(String), +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +pub enum ChallengeType { + #[serde(rename = "http-01")] + Http01, + #[serde(rename = "dns-01")] + Dns01, + #[serde(rename = "tls-alpn-01")] + TlsAlpn01, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ChallengeStatus { + Pending, + Processing, + Valid, + Invalid, +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum OrderStatus { + Pending, + Ready, + Processing, + Valid, + Invalid, +} + +#[derive(Clone, Copy, Debug)] +pub enum LetsEncrypt { + Production, + Staging, +} + +impl LetsEncrypt { + pub fn url(&self) -> &'static str { + match self { + LetsEncrypt::Production => "https://acme-v02.api.letsencrypt.org/directory", + LetsEncrypt::Staging => "https://acme-staging-v02.api.letsencrypt.org/directory", + } + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "UPPERCASE")] +pub(crate) enum SigningAlgorithm { + /// ECDSA using P-256 and SHA-256 + Es256, +} + +#[derive(Debug, Serialize)] +pub(crate) struct Empty {}