rust: initial implementation of an ACME client

This commit is contained in:
Dirkjan Ochtman 2022-01-25 15:06:38 +01:00 committed by Dirkjan Ochtman
commit 0060778f15
3 changed files with 668 additions and 0 deletions

13
Cargo.toml Normal file
View File

@ -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"

369
src/lib.rs Normal file
View File

@ -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<AccountInner>,
nonce: Option<String>,
#[allow(dead_code)]
order_url: String,
}
impl Order {
pub async fn authorizations(
&mut self,
authz_urls: &[String],
) -> Result<Vec<Authorization>, 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<OrderState, Error> {
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::<Challenge>(rsp).await?;
Ok(())
}
pub async fn certificate_chain(&mut self, cert_url: &str) -> Result<String, Error> {
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::<Problem>().await?.into()),
}
}
pub async fn challenge(&mut self, challenge_url: &str) -> Result<Challenge, Error> {
self.account.get(&mut self.nonce, challenge_url).await
}
pub async fn state(&mut self) -> Result<OrderState, Error> {
self.account.get(&mut self.nonce, &self.order_url).await
}
}
#[derive(Clone)]
pub struct Account {
inner: Arc<AccountInner>,
}
impl Account {
pub async fn create(account: &NewAccount<'_>, server_url: &str) -> Result<Account, Error> {
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::<Problem>().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<D>(deserializer: D) -> Result<Self, D::Error>
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<T: DeserializeOwned>(
&self,
nonce: &mut Option<String>,
url: &str,
) -> Result<T, Error> {
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<String>,
url: &str,
) -> Result<Response, Error> {
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<String, Error> {
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<u8>,
thumb: String,
}
impl Key {
fn generate() -> Result<Self, Error> {
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<u8>) -> Result<Self, Error> {
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<Body, Error> {
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<String> {
rsp.headers()
.get(REPLAY_NONCE)
.and_then(|hv| String::from_utf8(hv.as_ref().to_vec()).ok())
}
fn base64(data: &impl Serialize) -> Result<String, serde_json::Error> {
Ok(base64::encode_config(
serde_json::to_vec(data)?,
URL_SAFE_NO_PAD,
))
}
fn client() -> Result<reqwest::Client, reqwest::Error> {
reqwest::Client::builder().redirect(Policy::none()).build()
}
const JOSE_JSON: &str = "application/jose+json";
const REPLAY_NONCE: &str = "Replay-Nonce";

286
src/types.rs Normal file
View File

@ -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<T: DeserializeOwned>(rsp: Response) -> Result<T, Error> {
let status = rsp.status();
match status.is_client_error() || status.is_server_error() {
false => Ok(rsp.json().await?),
true => Err(rsp.json::<Self>().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<Digest, serde_json::Error> {
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<Problem>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderState {
pub status: OrderStatus,
pub authorizations: Vec<String>,
pub error: Option<Problem>,
pub finalize: String,
pub certificate: Option<String>,
}
#[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<Challenge>,
},
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 {}