commit aa23068293b07c4aba370c59286a287a4c6d2d45 Author: Wolfgang Bumiller Date: Thu Feb 4 11:39:38 2021 +0100 import Signed-off-by: Wolfgang Bumiller diff --git a/.cargo/config b/.cargo/config new file mode 100644 index 00000000..3b5b6e48 --- /dev/null +++ b/.cargo/config @@ -0,0 +1,5 @@ +[source] +[source.debian-packages] +directory = "/usr/share/cargo/registry" +[source.crates-io] +replace-with = "debian-packages" diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..96ef6c0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..132c08ac --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "proxmox-acme-rs" +version = "0.1.0" +authors = ["Wolfgang Bumiller "] +edition = "2018" +license = "AGPL-3" +description = "ACME client library" +exclude = [ + "build", + "debian", +] + +[dependencies] +base64 = "0.12.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +openssl = "0.10.29" + +curl = { version = "0.4.33", optional = true } + +[features] +default = [] +client = ["curl"] + +[dev-dependencies] +anyhow = "1.0" diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..6301c174 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +.PHONY: all +all: check + +.PHONY: check +check: + cargo test --all-features + +.PHONY: dinstall +dinstall: deb + sudo -k dpkg -i build/librust-*.deb + +.PHONY: build +build: + rm -rf build + rm -f debian/control + mkdir build + debcargo package \ + --config "$(PWD)/debian/debcargo.toml" \ + --changelog-ready \ + --no-overlay-write-back \ + --directory "$(PWD)/build/proxmox-acme-rs" \ + "proxmox-acme-rs" \ + "$$(dpkg-parsechangelog -l "debian/changelog" -SVersion | sed -e 's/-.*//')" + echo system >build/rust-toolchain + rm -f build/proxmox-acme-rs/Cargo.lock + find build/proxmox-acme-rs/debian -name '*.hint' -delete + cp build/proxmox-acme-rs/debian/control debian/control + +.PHONY: deb +deb: build + (cd build/proxmox-acme-rs && CARGO=/usr/bin/cargo RUSTC=/usr/bin/rustc dpkg-buildpackage -b -uc -us) + lintian build/*.deb + +.PHONY: clean +clean: + rm -rf build *.deb *.buildinfo *.changes *.orig.tar.gz + cargo clean + +upload: deb + cd build; \ + dcmd --deb rust-proxmox-acme-rs_*.changes \ + | grep -v '.changes$$' \ + | tar -cf- -T- \ + | ssh -X repoman@repo.proxmox.com upload --product devel --dist buster diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 00000000..2ace5e4a --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +rust-proxmox-acme-rs (0.1.0-1) pve; urgency=medium + + * initial release + + -- Proxmox Support Team Tue, 09 Mar 2021 13:01:56 +0100 diff --git a/debian/control b/debian/control new file mode 100644 index 00000000..d3aa336f --- /dev/null +++ b/debian/control @@ -0,0 +1,62 @@ +Source: rust-proxmox-acme-rs +Section: rust +Priority: optional +Build-Depends: debhelper (>= 11), + dh-cargo (>= 18), + cargo:native , + rustc:native , + libstd-rust-dev , + librust-base64-0.12+default-dev , + librust-openssl-0.10+default-dev (>= 0.10.29-~~) , + librust-serde-1+default-dev , + librust-serde-1+derive-dev , + librust-serde-json-1+default-dev +Maintainer: Proxmox Support Team +Standards-Version: 4.4.1 +Vcs-Git: +Vcs-Browser: + +Package: librust-proxmox-acme-rs-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-base64-0.12+default-dev, + librust-openssl-0.10+default-dev (>= 0.10.29-~~), + librust-serde-1+default-dev, + librust-serde-1+derive-dev, + librust-serde-json-1+default-dev +Suggests: + librust-proxmox-acme-rs+client-dev (= ${binary:Version}) +Provides: + librust-proxmox-acme-rs+default-dev (= ${binary:Version}), + librust-proxmox-acme-rs-0-dev (= ${binary:Version}), + librust-proxmox-acme-rs-0+default-dev (= ${binary:Version}), + librust-proxmox-acme-rs-0.1-dev (= ${binary:Version}), + librust-proxmox-acme-rs-0.1+default-dev (= ${binary:Version}), + librust-proxmox-acme-rs-0.1.0-dev (= ${binary:Version}), + librust-proxmox-acme-rs-0.1.0+default-dev (= ${binary:Version}) +Description: ACME client library - Rust source code + This package contains the source for the Rust proxmox-acme-rs crate, packaged + by debcargo for use with cargo and dh-cargo. + +Package: librust-proxmox-acme-rs+client-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-proxmox-acme-rs-dev (= ${binary:Version}), + librust-curl-0.4+default-dev (>= 0.4.33-~~) +Provides: + librust-proxmox-acme-rs+curl-dev (= ${binary:Version}), + librust-proxmox-acme-rs-0+client-dev (= ${binary:Version}), + librust-proxmox-acme-rs-0+curl-dev (= ${binary:Version}), + librust-proxmox-acme-rs-0.1+client-dev (= ${binary:Version}), + librust-proxmox-acme-rs-0.1+curl-dev (= ${binary:Version}), + librust-proxmox-acme-rs-0.1.0+client-dev (= ${binary:Version}), + librust-proxmox-acme-rs-0.1.0+curl-dev (= ${binary:Version}) +Description: ACME client library - feature "client" and 1 more + This metapackage enables feature "client" for the Rust proxmox-acme-rs crate, + by pulling in any additional dependencies needed by that feature. + . + Additionally, this package also provides the "curl" feature. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 00000000..477c3058 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,16 @@ +Copyright (C) 2020-2021 Proxmox Server Solutions GmbH + +This software is written by Proxmox Server Solutions GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/debian/debcargo.toml b/debian/debcargo.toml new file mode 100644 index 00000000..703440fc --- /dev/null +++ b/debian/debcargo.toml @@ -0,0 +1,8 @@ +overlay = "." +crate_src_path = ".." +maintainer = "Proxmox Support Team " + +[source] +# TODO: update once public +vcs_git = "" +vcs_browser = "" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..32a9786f --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +edition = "2018" diff --git a/src/account.rs b/src/account.rs new file mode 100644 index 00000000..57c22461 --- /dev/null +++ b/src/account.rs @@ -0,0 +1,304 @@ +use std::convert::TryFrom; + +use openssl::pkey::{PKey, Private}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::b64u; +use crate::directory::Directory; +use crate::jws::Jws; +use crate::key::PublicKey; +use crate::order::{NewOrder, OrderData}; +use crate::request::Request; +use crate::Error; + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Account { + /// Account location URL. + pub location: String, + + /// Acme account data. + pub data: AccountData, + + /// base64url encoded PEM formatted private key. + pub private_key: String, +} + +impl Account { + pub fn from_parts(location: String, private_key: String, data: AccountData) -> Self { + Self { + location, + private_key, + data, + } + } + + pub fn creator() -> AccountCreator { + AccountCreator::default() + } + + /// The returned `NewOrder`'s `request` option is *guaranteed* to be `Some(Request)`. + pub fn new_order( + &self, + order: &OrderData, + directory: &Directory, + nonce: &str, + ) -> Result { + let key = PKey::private_key_from_pem(self.private_key.as_bytes())?; + + if order.identifiers.is_empty() { + return Err(Error::EmptyOrder); + } + + let url = directory.new_order_url(); + let body = serde_json::to_string(&Jws::new( + &key, + Some(self.location.clone()), + url.to_owned(), + nonce.to_owned(), + order, + )?)?; + + let request = Request { + url: url.to_owned(), + method: "POST", + content_type: crate::request::JSON_CONTENT_TYPE, + body, + expected: crate::request::CREATED, + }; + + Ok(NewOrder::new(request)) + } + + /// Prepare a "POST-as-GET" request to fetch data. + pub fn get_request(&self, url: &str, nonce: &str) -> Result { + let key = PKey::private_key_from_pem(self.private_key.as_bytes())?; + let body = serde_json::to_string(&Jws::new_full( + &key, + Some(self.location.clone()), + url.to_owned(), + nonce.to_owned(), + String::new(), + )?)?; + + Ok(Request { + url: url.to_owned(), + method: "POST", + content_type: crate::request::JSON_CONTENT_TYPE, + body, + expected: 200, + }) + } + + /// Prepare a JSON POST request. + pub fn post_request( + &self, + url: &str, + nonce: &str, + data: &T, + ) -> Result { + let key = PKey::private_key_from_pem(self.private_key.as_bytes())?; + let body = serde_json::to_string(&Jws::new( + &key, + Some(self.location.clone()), + url.to_owned(), + nonce.to_owned(), + data, + )?)?; + + Ok(Request { + url: url.to_owned(), + method: "POST", + content_type: crate::request::JSON_CONTENT_TYPE, + body, + expected: 200, + }) + } + + /// Get the "key authorization" for a token. + pub fn key_authorization(&self, token: &str) -> Result { + let key = PKey::private_key_from_pem(self.private_key.as_bytes())?; + let thumbprint = PublicKey::try_from(&*key)?.thumbprint()?; + Ok(format!("{}.{}", token, thumbprint)) + } + + /// Get the TXT field value for a dns-01 token. This is the base64url encoded sha256 digest of + /// the key authorization value. + pub fn dns_01_txt_value(&self, token: &str) -> Result { + let key_authorization = self.key_authorization(token)?; + let digest = openssl::sha::sha256(key_authorization.as_bytes()); + Ok(b64u::encode(&digest)) + } +} + +#[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum AccountStatus { + #[serde(rename = "")] + New, + Valid, + Deactivated, + Revoked, +} + +impl AccountStatus { + #[inline] + fn new() -> Self { + AccountStatus::New + } + + #[inline] + fn is_new(&self) -> bool { + *self == AccountStatus::New + } +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountData { + #[serde( + skip_serializing_if = "AccountStatus::is_new", + default = "AccountStatus::new" + )] + status: AccountStatus, + + #[serde(skip_serializing_if = "Option::is_none")] + orders: Option, + + #[serde(skip_serializing_if = "Vec::is_empty", default)] + contact: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + terms_of_service_agreed: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + external_account_binding: Option, + + #[serde(default = "default_true", skip_serializing_if = "is_false")] + only_return_existing: bool, +} + +#[inline] +fn default_true() -> bool { + true +} + +#[inline] +fn is_false(b: &bool) -> bool { + !*b +} + +#[derive(Default)] +#[must_use = "when creating an account you must pass the response to AccountCreator::response()!"] +pub struct AccountCreator { + contact: Vec, + terms_of_service_agreed: bool, + key: Option>, +} + +impl AccountCreator { + /// Replace the contact infor with the provided ACME compatible data. + pub fn set_contacts(mut self, contact: Vec) -> Self { + self.contact = contact; + self + } + + /// Append a contact string. + pub fn contact(mut self, contact: String) -> Self { + self.contact.push(contact); + self + } + + /// Append an email address to the contact list. + pub fn email(self, email: String) -> Self { + self.contact(format!("mailto:{}", email)) + } + + /// Change whether the account agrees to the terms of service. Use the directory's or client's + /// `terms_of_service_url()` method to present the user with the Terms of Service. + pub fn agree_to_tos(mut self, agree: bool) -> Self { + self.terms_of_service_agreed = agree; + self + } + + /// Generate a new RSA key of the specified key size. + pub fn generate_rsa_key(self, bits: u32) -> Result { + let key = openssl::rsa::Rsa::generate(bits)?; + Ok(self.with_key(PKey::from_rsa(key)?)) + } + + /// Generate a new P-256 EC key. + pub fn generate_ec_key(self) -> Result { + let key = openssl::ec::EcKey::generate( + openssl::ec::EcGroup::from_curve_name(openssl::nid::Nid::X9_62_PRIME256V1)?.as_ref(), + )?; + Ok(self.with_key(PKey::from_ec_key(key)?)) + } + + /// Use an existing key. Note that only RSA and EC keys using the `P-256` curve are currently + /// supported, however, this will not be checked at this point. + pub fn with_key(mut self, key: PKey) -> Self { + self.key = Some(key); + self + } + + /// Prepare a HTTP request to create this account. + /// + /// Changes to the user data made after this will have no effect on the account generated with + /// the resulting request. + /// Changing the private key between using the request and passing the response to + /// [`response()`] will render the account unusable! + pub fn request(&self, directory: &Directory, nonce: &str) -> Result { + let key = self.key.as_deref().ok_or_else(|| Error::MissingKey)?; + + let data = AccountData { + orders: None, + status: AccountStatus::New, + contact: self.contact.clone(), + terms_of_service_agreed: if self.terms_of_service_agreed { + Some(true) + } else { + None + }, + external_account_binding: None, + only_return_existing: false, + }; + + let url = directory.new_account_url(); + let body = serde_json::to_string(&Jws::new( + key, + None, + url.to_owned(), + nonce.to_owned(), + &data, + )?)?; + + Ok(Request { + url: url.to_owned(), + method: "POST", + content_type: crate::request::JSON_CONTENT_TYPE, + body, + expected: crate::request::CREATED, + }) + } + + /// After issuing the request from [`request()`], the response's `Location` header and body + /// must be passed to this for verification and to create an account which is to be persisted! + pub fn response(self, location_header: String, response_body: &[u8]) -> Result { + let private_key = self + .key + .ok_or(Error::MissingKey)? + .private_key_to_pem_pkcs8()?; + let private_key = String::from_utf8(private_key).map_err(|_| { + Error::Custom(format!("PEM key contained illegal non-utf-8 characters")) + })?; + + Ok(Account { + location: location_header, + data: serde_json::from_slice(response_body) + .map_err(|err| Error::BadAccountData(err.to_string()))?, + private_key, + }) + } +} diff --git a/src/authorization.rs b/src/authorization.rs new file mode 100644 index 00000000..051306f4 --- /dev/null +++ b/src/authorization.rs @@ -0,0 +1,57 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::order::Identifier; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Status { + Deactivated, + Expired, + Invalid, + Pending, + Revoked, + Valid, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Authorization { + pub identifier: Identifier, + + pub status: Status, + + #[serde(skip_serializing_if = "Option::is_none")] + pub expires: Option, + + pub challenges: Vec, + + #[serde(default, skip_serializing_if = "is_false")] + pub wildcard: bool, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Challenge { + #[serde(rename = "type")] + pub ty: String, + + #[serde(flatten)] + pub data: HashMap, +} + +impl Challenge { + /// Most challenges have a `token` used for key authorizations. This is a convenience helper to + /// access it. + pub fn token(&self) -> Option<&str> { + self.data.get("token").and_then(Value::as_str) + } +} + +/// Serde helper +#[inline] +fn is_false(b: &bool) -> bool { + !*b +} diff --git a/src/b64u.rs b/src/b64u.rs new file mode 100644 index 00000000..a4f8ce0a --- /dev/null +++ b/src/b64u.rs @@ -0,0 +1,38 @@ +fn config() -> base64::Config { + base64::Config::new(base64::CharacterSet::UrlSafe, false) +} + +/// Encode bytes as base64url into a `String`. +pub fn encode(data: &[u8]) -> String { + base64::encode_config(data, config()) +} + +// curiously currently unused as we don't deserialize any of that +// /// Decode bytes from a base64url string. +// pub fn decode(data: &str) -> Result, base64::DecodeError> { +// base64::decode_config(data, config()) +// } + +/// Our serde module for encoding bytes as base64url encoded strings. +pub mod bytes { + use serde::{Serialize, Serializer}; + //use serde::{Deserialize, Deserializer}; + + pub fn serialize(data: &[u8], serializer: S) -> Result + where + S: Serializer, + { + super::encode(data).serialize(serializer) + } + + // curiously currently unused as we don't deserialize any of that + // pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + // where + // D: Deserializer<'de>, + // { + // use serde::de::Error; + + // Ok(super::decode(&String::deserialize(deserializer)?) + // .map_err(|e| D::Error::custom(e.to_string()))?) + // } +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 00000000..2ab0a4d2 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,605 @@ +use std::convert::TryFrom; + +use curl::easy; +use serde::{Deserialize, Serialize}; + +use crate::b64u; +use crate::error; +use crate::order::OrderData; +use crate::request::ErrorResponse; +use crate::{Account, Authorization, Challenge, Directory, Error, Order, Request}; + +macro_rules! format_err { + ($($fmt:tt)*) => { Error::Client(format!($($fmt)*)) }; +} + +macro_rules! bail { + ($($fmt:tt)*) => {{ return Err(format_err!($($fmt)*)); }} +} + +/// Low level HTTP response structure. +pub struct HttpResponse { + pub body: Vec, + pub status: u16, + pub headers: Headers, +} + +impl HttpResponse { + /// Check the HTTP status code for a success code (200..299). + pub fn is_success(&self) -> bool { + self.status >= 200 && self.status < 300 + } + + /// Convenience shortcut to perform json deserialization of the returned body. + pub fn json Deserialize<'a>>(&self) -> Result { + Ok(serde_json::from_slice(&self.body)?) + } + + /// Access the raw body as bytes. + pub fn bytes(&self) -> &[u8] { + &self.body + } + + /// Get the returned location header. Borrowing shortcut to `self.headers.location`. + pub fn location(&self) -> Option<&str> { + self.headers.location.as_deref() + } + + /// Convenience helper to assert that a location header was part of the response. + pub fn location_required(&mut self) -> Result { + self.headers + .location + .take() + .ok_or_else(|| format_err!("missing Location header")) + } +} + +/// Contains headers from the HTTP response which are relevant parts of the Acme API. +/// +/// Note that access to the `nonce` header is internal to this crate only, since a nonce will +/// always be moved out of the response into the `Client` whenever a new nonce is received. +#[derive(Default)] +pub struct Headers { + pub location: Option, + nonce: Option, +} + +impl Headers { + fn read_header(&mut self, header: &[u8]) { + let (name, value) = match parse_header(header) { + Some(h) => h, + None => return, + }; + + if name.eq_ignore_ascii_case(crate::REPLAY_NONCE) { + self.nonce = Some(value.to_owned()); + } else if name.eq_ignore_ascii_case(crate::LOCATION) { + self.location = Some(value.to_owned()); + } + } +} + +struct Inner { + easy: easy::Easy, + nonce: Option, +} + +impl Inner { + pub fn new() -> Self { + Self { + easy: easy::Easy::new(), + nonce: None, + } + } + + pub fn execute( + &mut self, + method: &[u8], + url: &str, + request_body: Option<&[u8]>, + ) -> Result { + let mut body = Vec::new(); + let mut headers = Headers::default(); + let mut upload; + + match method { + b"POST" => self.easy.post(true)?, + b"GET" => self.easy.get(true)?, + b"HEAD" => self.easy.nobody(true)?, + other => bail!("invalid http method: {:?}", other), + } + + self.easy.url(url)?; + + { + let mut transfer = self.easy.transfer(); + + transfer.write_function(|data| { + body.extend(data); + Ok(data.len()) + })?; + + transfer.header_function(|data| { + headers.read_header(data); + true + })?; + + if let Some(body) = request_body { + upload = body; + transfer.read_function(|dest| { + let len = upload.len().min(dest.len()); + dest[..len].copy_from_slice(&upload[..len]); + upload = &upload[len..]; + Ok(len) + })?; + } + + transfer.perform()?; + } + + let status = self.easy.response_code()?; + let status = + u16::try_from(status).map_err(|_| format_err!("invalid status code: {}", status))?; + Ok(HttpResponse { + body, + status, + headers, + }) + } + + /// Low-level API to run an n API request. This automatically updates the current nonce! + fn run_request(&mut self, request: Request) -> Result { + self.easy.reset(); + + let body = if !request.content_type.is_empty() { + let mut headers = easy::List::new(); + headers.append(&format!("Content-Type: {}", request.content_type))?; + self.easy + .http_headers(headers) + .map_err(|err| format_err!("curl error: {}", err))?; + Some(request.body.as_bytes()) + } else { + None + }; + + let mut response = self + .execute(request.method.as_bytes(), &request.url, body) + .map_err({ + // borrow fixup: + let method = &request.method; + let url = &request.url; + move |err| format_err!("failed to execute {} request to {}: {}", method, url, err) + })?; + + let got_nonce = self.update_nonce(&mut response)?; + + if response.is_success() { + if response.status != request.expected { + return Err(Error::InvalidApi(format!( + "API server responded with unexpected status code: {:?}", + response.status + ))); + } + return Ok(response); + } + + let error: ErrorResponse = response.json().map_err(|err| { + format_err!("error status with improper error ACME response: {}", err) + })?; + + if error.ty == error::BAD_NONCE { + if !got_nonce { + return Err(Error::InvalidApi( + "badNonce without a new Replay-Nonce header".to_string(), + )); + } + return Err(Error::BadNonce); + } + + Err(Error::Api(error)) + } + + /// If the response contained a nonce, update our nonce and return `true`, otherwise return + /// `false`. + fn update_nonce(&mut self, response: &mut HttpResponse) -> Result { + match response.headers.nonce.take() { + Some(nonce) => { + self.nonce = Some(nonce); + Ok(true) + } + None => Ok(false), + } + } + + /// Update the nonce, if there isn't one it is an error. + fn must_update_nonce(&mut self, response: &mut HttpResponse) -> Result<(), Error> { + if !self.update_nonce(response)? { + bail!("newNonce URL did not return a nonce"); + } + Ok(()) + } + + /// Update the Nonce. + fn new_nonce(&mut self, new_nonce_url: &str) -> Result<(), Error> { + let mut response = self.execute(b"HEAD", new_nonce_url, None).map_err(|err| { + Error::InvalidApi(format!("failed to get HEAD of newNonce URL: {}", err)) + })?; + + if !response.is_success() { + bail!("HEAD on newNonce URL returned error"); + } + + self.must_update_nonce(&mut response)?; + + Ok(()) + } + + /// Make sure a nonce is available without forcing renewal. + fn nonce(&mut self, new_nonce_url: &str) -> Result<&str, Error> { + if self.nonce.is_none() { + self.new_nonce(new_nonce_url)?; + } + self.nonce + .as_deref() + .ok_or_else(|| format_err!("failed to get nonce")) + } +} + +/// A blocking Acme client using curl's `Easy` interface. +pub struct Client { + inner: Inner, + directory: Option, + account: Option, + directory_url: String, +} + +impl Client { + /// Create a new Client. This has no account associated with it yet, so the next step is to + /// either attach an existing `Account` or create a new one. + pub fn new(directory_url: String) -> Self { + Self { + inner: Inner::new(), + directory: None, + account: None, + directory_url, + } + } + + /// Set the account this client should use. + pub fn set_account(&mut self, account: Account) { + self.account = Some(account); + } + + /// Get the Directory information. + pub fn directory(&mut self) -> Result<&Directory, Error> { + Self::get_directory(&mut self.inner, &mut self.directory, &self.directory_url) + } + + /// Get the Directory information. + fn get_directory<'a>( + inner: &'_ mut Inner, + directory: &'a mut Option, + directory_url: &str, + ) -> Result<&'a Directory, Error> { + if let Some(d) = directory { + return Ok(d); + } + + let response = inner + .execute(b"GET", directory_url, None) + .map_err(|err| Error::InvalidApi(format!("failed to get directory info: {}", err)))?; + + if !response.is_success() { + bail!( + "GET on the directory URL returned error status ({})", + response.status + ); + } + + *directory = Some(Directory::from_parts( + directory_url.to_string(), + response.json()?, + )); + Ok(directory.as_ref().unwrap()) + } + + /// Get the current account, if there is one. + pub fn account(&self) -> Option<&Account> { + self.account.as_ref() + } + + /// Convenience method to get the ToS URL from the contained `Directory`. + /// + /// This requires mutable self as the directory information may be lazily loaded, which can + /// fail. + pub fn terms_of_service_url(&mut self) -> Result, Error> { + Ok(self.directory()?.terms_of_service_url()) + } + + /// Get a fresh nonce (this should normally not be required as nonces are updated + /// automatically, even when a `badNonce` error occurs, which according to the ACME API + /// specification should include a new valid nonce in its headers anyway). + pub fn new_nonce(&mut self) -> Result<(), Error> { + let was_none = self.inner.nonce.is_none(); + let directory = + Self::get_directory(&mut self.inner, &mut self.directory, &self.directory_url)?; + if was_none && self.inner.nonce.is_some() { + // this was the first call and we already got a nonce from querying the directory + return Ok(()); + } + + // otherwise actually call up to get a new nonce + self.inner.new_nonce(directory.new_nonce_url()) + } + + /// borrow helper + fn nonce<'a>(inner: &'a mut Inner, directory: &'_ Directory) -> Result<&'a str, Error> { + inner.nonce(directory.new_nonce_url()) + } + + /// Convenience method to create a new account with a list of ACME compatible contact strings + /// (eg. `mailto:someone@example.com`). + /// + /// Please remember to persist the returned `Account` structure somewhere to not lose access to + /// the account! + /// + /// If an RSA key size is provided, an RSA key will be generated. Otherwise an EC key using the + /// P-256 curve will be generated. + pub fn new_account( + &mut self, + contact: Vec, + tos_agreed: bool, + rsa_bits: Option, + ) -> Result<&Account, Error> { + let account = Account::creator() + .set_contacts(contact) + .agree_to_tos(tos_agreed); + let account = if let Some(bits) = rsa_bits { + account.generate_rsa_key(bits)? + } else { + account.generate_ec_key()? + }; + + self.register_account(account) + } + + pub fn register_account( + &mut self, + account: crate::account::AccountCreator, + ) -> Result<&Account, Error> { + let mut retry = retry(); + let mut response = loop { + retry.tick()?; + + let directory = + Self::get_directory(&mut self.inner, &mut self.directory, &self.directory_url)?; + let nonce = Self::nonce(&mut self.inner, directory)?; + let request = account.request(directory, nonce)?; + match self.run_request(request) { + Ok(response) => break response, + Err(err) if err.is_bad_nonce() => continue, + Err(err) => return Err(err.into()), + } + }; + + let account = account.response(response.location_required()?, response.bytes().as_ref())?; + + self.account = Some(account); + Ok(self.account.as_ref().unwrap()) + } + + fn need_account(account: &Option) -> Result<&Account, Error> { + account + .as_ref() + .ok_or_else(|| format_err!("cannot use client without an account")) + } + + /// Update account data. + /// + /// Low-level version: we allow arbitrary data to be passed to the remote here, it's up to the + /// user to know what to do for now. + pub fn update_account(&mut self, data: &T) -> Result<&Account, Error> { + let account = Self::need_account(&self.account)?; + + let mut retry = retry(); + let response = loop { + retry.tick()?; + let directory = + Self::get_directory(&mut self.inner, &mut self.directory, &self.directory_url)?; + let nonce = Self::nonce(&mut self.inner, directory)?; + let request = account.post_request(&account.location, &nonce, data)?; + let response = match self.inner.run_request(request) { + Ok(response) => response, + Err(err) if err.is_bad_nonce() => continue, + Err(err) => return Err(err.into()), + }; + + break response; + }; + + // unwrap: we asserted we have an account at the top of the method! + let account = self.account.as_mut().unwrap(); + account.data = response.json()?; + Ok(account) + } + + /// Method to create a new order for a set of domains. + /// + /// Please remember to persist the order somewhere (ideally along with the account data) in + /// order to finish & query it later on. + pub fn new_order(&mut self, domains: Vec) -> Result { + let account = Self::need_account(&self.account)?; + + let order = domains + .into_iter() + .fold(OrderData::new(), |order, domain| order.domain(domain)); + + let mut retry = retry(); + loop { + retry.tick()?; + + let directory = + Self::get_directory(&mut self.inner, &mut self.directory, &self.directory_url)?; + let nonce = Self::nonce(&mut self.inner, directory)?; + let mut new_order = account.new_order(&order, directory, nonce)?; + let mut response = match self.inner.run_request(new_order.request.take().unwrap()) { + Ok(response) => response, + Err(err) if err.is_bad_nonce() => continue, + Err(err) => return Err(err.into()), + }; + + return new_order.response(response.location_required()?, response.bytes().as_ref()); + } + } + + /// Assuming the provided URL is an 'Authorization' URL, get and deserialize it. + pub fn get_authorization(&mut self, url: &str) -> Result { + Ok(self.post_as_get(url)?.json()?) + } + + /// Assuming the provided URL is an 'Order' URL, get and deserialize it. + pub fn get_order(&mut self, url: &str) -> Result { + Ok(self.post_as_get(url)?.json()?) + } + + /// Low level "POST-as-GET" request. + pub fn post_as_get(&mut self, url: &str) -> Result { + let account = Self::need_account(&self.account)?; + + let mut retry = retry(); + loop { + retry.tick()?; + + let directory = + Self::get_directory(&mut self.inner, &mut self.directory, &self.directory_url)?; + let nonce = Self::nonce(&mut self.inner, directory)?; + let request = account.get_request(url, nonce)?; + match self.inner.run_request(request) { + Ok(response) => return Ok(response), + Err(err) if err.is_bad_nonce() => continue, + Err(err) => return Err(err.into()), + } + } + } + + /// Low level POST request. + pub fn post(&mut self, url: &str, data: &T) -> Result { + let account = Self::need_account(&self.account)?; + + let mut retry = retry(); + loop { + retry.tick()?; + + let directory = + Self::get_directory(&mut self.inner, &mut self.directory, &self.directory_url)?; + let nonce = Self::nonce(&mut self.inner, directory)?; + let request = account.post_request(url, nonce, data)?; + match self.inner.run_request(request) { + Ok(response) => return Ok(response), + Err(err) if err.is_bad_nonce() => continue, + Err(err) => return Err(err.into()), + } + } + } + + /// Request challenge validation. Afterwards, the challenge should be polled. + pub fn request_challenge_validation(&mut self, url: &str) -> Result { + Ok(self.post(url, &serde_json::json!({}))?.json()?) + } + + /// Shortcut to `account().ok_or_else(...).key_authorization()`. + pub fn key_authorization(&self, token: &str) -> Result { + Self::need_account(&self.account)?.key_authorization(token) + } + + /// Shortcut to `account().ok_or_else(...).dns_01_txt_value()`. + /// the key authorization value. + pub fn dns_01_txt_value(&self, token: &str) -> Result { + Self::need_account(&self.account)?.dns_01_txt_value(token) + } + + /// Low-level API to run an n API request. This automatically updates the current nonce! + pub fn run_request(&mut self, request: Request) -> Result { + self.inner.run_request(request) + } + + /// Finalize an Order via its `finalize` URL property and the DER encoded CSR. + pub fn finalize(&mut self, url: &str, csr: &[u8]) -> Result<(), Error> { + let csr = b64u::encode(csr); + let data = serde_json::json!({ "csr": csr }); + self.post(url, &data)?; + Ok(()) + } + + /// Download a certificate via its 'certificate' URL property. + /// + /// The certificate will be a PEM certificate chain. + pub fn get_certificate(&mut self, url: &str) -> Result, Error> { + Ok(self.post_as_get(url)?.body) + } + + /// Revoke an existing certificate (PEM or DER formatted). + pub fn revoke_certificate( + &mut self, + certificate: &[u8], + reason: Option, + ) -> Result<(), Error> { + // TODO: This can also work without an account. + let account = Self::need_account(&self.account)?; + + let cert = if certificate.starts_with(b"-----BEGIN CERTIFICATE-----") { + b64u::encode(&openssl::x509::X509::from_pem(certificate)?.to_der()?) + } else { + b64u::encode(certificate) + }; + + let data = match reason { + Some(reason) => serde_json::json!({ "certificate": cert, "reason": reason }), + None => serde_json::json!({ "certificate": cert }), + }; + + let mut retry = retry(); + loop { + retry.tick()?; + + let directory = + Self::get_directory(&mut self.inner, &mut self.directory, &self.directory_url)?; + let nonce = Self::nonce(&mut self.inner, directory)?; + let request = account.post_request(&directory.data.revoke_cert, nonce, &data)?; + match self.inner.run_request(request) { + Ok(_response) => return Ok(()), + Err(err) if err.is_bad_nonce() => continue, + Err(err) => return Err(err.into()), + } + } + } +} + +fn parse_header(data: &[u8]) -> Option<(&str, &str)> { + let colon = data.iter().position(|&b| b == b':')?; + + let name = std::str::from_utf8(&data[..colon]).ok()?; + + let value = &data[(colon + 1)..]; + let value_start = value.iter().position(|&b| !b.is_ascii_whitespace())?; + let value = std::str::from_utf8(&value[value_start..]).ok()?; + + Some((name.trim(), value.trim())) +} + +/// bad nonce retry count helper +struct Retry(usize); + +const fn retry() -> Retry { + Retry(0) +} + +impl Retry { + fn tick(&mut self) -> Result<(), Error> { + if self.0 >= 3 { + bail!("kept getting a badNonce error!"); + } + self.0 += 1; + Ok(()) + } +} diff --git a/src/directory.rs b/src/directory.rs new file mode 100644 index 00000000..50294d26 --- /dev/null +++ b/src/directory.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +pub struct Directory { + pub url: String, + pub data: DirectoryData, +} + +/// The ACME Directory object structure. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryData { + pub new_account: String, + pub new_nonce: String, + pub new_order: String, + pub revoke_cert: String, + pub key_change: String, + pub meta: Meta, +} + +/// The directory's "meta" object. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Meta { + #[serde(skip_serializing_if = "Option::is_none")] + pub terms_of_service: Option, +} + +impl Directory { + /// Create a `Directory` given the parsed `DirectoryData` of a `GET` request to the directory + /// URL. + pub fn from_parts(url: String, data: DirectoryData) -> Self { + Self { url, data } + } + + /// Get the ToS URL. + pub fn terms_of_service_url(&self) -> Option<&str> { + self.data.meta.terms_of_service.as_deref() + } + + /// Get the "newNonce" URL. Use `HEAD` requests on this to get a new nonce. + pub fn new_nonce_url(&self) -> &str { + &self.data.new_nonce + } + + pub(crate) fn new_account_url(&self) -> &str { + &self.data.new_account + } + + pub(crate) fn new_order_url(&self) -> &str { + &self.data.new_order + } + + /// Access to the in the Acme spec defined metadata structure. + /// Currently only contains the ToS URL already exposed via the `terms_of_service_url()` + /// method. + pub fn meta(&self) -> &Meta { + &self.data.meta + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..eb6015be --- /dev/null +++ b/src/error.rs @@ -0,0 +1,133 @@ +use std::fmt; + +use openssl::error::ErrorStack as SslErrorStack; + +pub const BAD_NONCE: &str = "urn:ietf:params:acme:error:badNonce"; +pub const USER_ACTION_REQUIRED: &str = "urn:ietf:params:acme:error:userActionRequired"; + +/// Error types returned by this crate. +#[derive(Debug)] +pub enum Error { + /// A `badNonce` API response. The request should be retried with the new nonce received along + /// with this response. + BadNonce, + + /// A `userActionRequired` API response. Typically this means there was a change to the ToS and + /// the user has to agree to the new terms. + UserActionRequired(String), + + /// Other error repsonses from the Acme API not handled specially. + Api(crate::request::ErrorResponse), + + /// The Acme API behaved unexpectedly. + InvalidApi(String), + + /// Tried to use an `Account` or `AccountCreator` without a private key. + MissingKey, + + /// Tried to create an `Account` without providing a single contact info. + MissingContactInfo, + + /// Tried to use an empty `Order`. + EmptyOrder, + + /// A raw `openssl::PKey` containing an unsupported key was passed. + UnsupportedKeyType, + + /// A raw `openssl::PKey` or `openssl::EcKey` with an unsupported curve was passed. + UnsupportedGroup, + + /// Failed to parse the account data returned by the API upon account creation. + BadAccountData(String), + + /// Failed to parse the order data returned by the API from a new-order request. + BadOrderData(String), + + /// An openssl error occurred during a crypto operation. + Ssl(SslErrorStack), + + /// An otherwise uncaught serde error happened. + Json(serde_json::Error), + + /// Can be used by the user for textual error messages without having to downcast to regular + /// acme errors. + Custom(String), + + /// If built with the `client` feature, this is where general curl/network errors end up. + /// This is usually a `curl::Error`, however in order to provide an API which is not + /// feature-dependent, this variant is always present and contains a boxed `dyn Error`. + HttpClient(Box), + + /// If built with the `client` feature, this is where client specific errors which are not from + /// errors forwarded from `curl` end up. + Client(String), +} + +impl Error { + pub fn custom(s: T) -> Self { + Error::Custom(s.to_string()) + } + + /// Convenience method to check if this error represents a bad nonce error in which case the + /// request needs to be re-created using a new nonce. + pub fn is_bad_nonce(&self) -> bool { + matches!(self, Error::BadNonce) + } +} + +impl std::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Error::Api(err) => match err.detail.as_deref() { + Some(detail) => write!(f, "{}: {}", err.ty, detail), + None => fmt::Display::fmt(&err.ty, f), + }, + Error::InvalidApi(err) => write!(f, "Acme Server API misbehaved: {}", err), + Error::BadNonce => f.write_str("bad nonce, please retry with a new nonce"), + Error::UserActionRequired(err) => write!(f, "user action required: {}", err), + Error::MissingKey => f.write_str("cannot build an account without a key"), + Error::MissingContactInfo => f.write_str("account requires contact info"), + Error::EmptyOrder => f.write_str("cannot make an empty order"), + Error::UnsupportedKeyType => f.write_str("unsupported key type"), + Error::UnsupportedGroup => f.write_str("unsupported EC group"), + Error::BadAccountData(err) => { + write!(f, "bad response to account query or creation: {}", err) + } + Error::BadOrderData(err) => { + write!(f, "bad response to new-order query or creation: {}", err) + } + Error::Ssl(err) => fmt::Display::fmt(err, f), + Error::Json(err) => fmt::Display::fmt(err, f), + Error::Custom(err) => fmt::Display::fmt(err, f), + Error::HttpClient(err) => fmt::Display::fmt(err, f), + Error::Client(err) => fmt::Display::fmt(err, f), + } + } +} + +impl From for Error { + fn from(e: SslErrorStack) -> Self { + Error::Ssl(e) + } +} + +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Error::Json(e) + } +} + +impl From for Error { + fn from(e: crate::request::ErrorResponse) -> Self { + Error::Api(e) + } +} + +#[cfg(feature = "client")] +impl From for Error { + fn from(e: curl::Error) -> Self { + Error::HttpClient(Box::new(e)) + } +} diff --git a/src/json.rs b/src/json.rs new file mode 100644 index 00000000..e192d679 --- /dev/null +++ b/src/json.rs @@ -0,0 +1,43 @@ +use openssl::hash::Hasher; +use serde_json::Value; + +use crate::Error; + +pub fn to_hash_canonical(value: &Value, output: &mut Hasher) -> Result<(), Error> { + match value { + Value::Null | Value::String(_) | Value::Number(_) | Value::Bool(_) => { + serde_json::to_writer(output, &value)?; + } + Value::Array(list) => { + output.update(b"[")?; + let mut iter = list.iter(); + if let Some(item) = iter.next() { + to_hash_canonical(item, output)?; + for item in iter { + output.update(b",")?; + to_hash_canonical(item, output)?; + } + } + output.update(b"]")?; + } + Value::Object(map) => { + output.update(b"{")?; + let mut keys: Vec<&str> = map.keys().map(String::as_str).collect(); + keys.sort_unstable(); + let mut iter = keys.into_iter(); + if let Some(key) = iter.next() { + serde_json::to_writer(&mut *output, &key)?; + output.update(b":")?; + to_hash_canonical(&map[key], output)?; + for key in iter { + output.update(b",")?; + serde_json::to_writer(&mut *output, &key)?; + output.update(b":")?; + to_hash_canonical(&map[key], output)?; + } + } + output.update(b"}")?; + } + } + Ok(()) +} diff --git a/src/jws.rs b/src/jws.rs new file mode 100644 index 00000000..5c11dcb3 --- /dev/null +++ b/src/jws.rs @@ -0,0 +1,163 @@ +use std::convert::TryFrom; + +use openssl::hash::{Hasher, MessageDigest}; +use openssl::pkey::{HasPrivate, PKeyRef}; +use openssl::sign::Signer; +use serde::Serialize; + +use crate::b64u; +use crate::key::{Jwk, PublicKey}; +use crate::Error; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Protected { + alg: &'static str, + nonce: String, + url: String, + #[serde(flatten)] + key: KeyId, +} + +/// Acme requires to the use of *either* `jwk` *or* `kid` depending on the action taken. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum KeyId { + /// This is the actual JWK structure. + Jwk(Jwk), + + /// This should be the account location. + Kid(String), +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Jws { + protected: String, + payload: String, + signature: String, +} + +impl Jws { + pub fn new( + key: &PKeyRef

, + location: Option, + url: String, + nonce: String, + payload: &T, + ) -> Result + where + P: HasPrivate, + T: Serialize, + { + Self::new_full( + key, + location, + url, + nonce, + b64u::encode(serde_json::to_string(payload)?.as_bytes()), + ) + } + + pub fn new_full( + key: &PKeyRef

, + location: Option, + url: String, + nonce: String, + payload: String, + ) -> Result { + let jwk = Jwk::try_from(key)?; + + let pubkey = jwk.key.clone(); + let mut protected = Protected { + alg: "", + nonce, + url, + key: match location { + Some(location) => KeyId::Kid(location), + None => KeyId::Jwk(jwk), + }, + }; + + let digest: MessageDigest = match &pubkey { + PublicKey::Rsa(_) => Self::prepare_rsa(key, &mut protected)?, + PublicKey::Ec(_) => Self::prepare_ec(key, &mut protected)?, + }; + + let protected_data = b64u::encode(serde_json::to_string(&protected)?.as_bytes()); + + let signature = { + let prot = protected_data.as_bytes(); + let payload = payload.as_bytes(); + match &pubkey { + PublicKey::Rsa(_) => Self::sign_rsa(key, digest, prot, payload), + PublicKey::Ec(_) => Self::sign_ec(key, digest, prot, payload), + }? + }; + + let signature = b64u::encode(&signature); + + Ok(Jws { + protected: protected_data, + payload, + signature, + }) + } + + fn prepare_rsa

(_key: &PKeyRef

, protected: &mut Protected) -> Result + where + P: HasPrivate, + { + protected.alg = "RS256"; + Ok(MessageDigest::sha256()) + } + + fn prepare_ec

(_key: &PKeyRef

, protected: &mut Protected) -> Result + where + P: HasPrivate, + { + // Note: if we support >256 bit keys we'll want to also support using ES512 here probably + protected.alg = "ES256"; + Ok(MessageDigest::sha256()) + } + + fn sign_rsa

( + key: &PKeyRef

, + digest: MessageDigest, + protected: &[u8], + payload: &[u8], + ) -> Result, Error> + where + P: HasPrivate, + { + let mut signer = Signer::new(digest, key)?; + signer.set_rsa_padding(openssl::rsa::Padding::PKCS1)?; + signer.update(protected)?; + signer.update(b".")?; + signer.update(payload)?; + Ok(signer.sign_to_vec()?) + } + + fn sign_ec

( + key: &PKeyRef

, + digest: MessageDigest, + protected: &[u8], + payload: &[u8], + ) -> Result, Error> + where + P: HasPrivate, + { + let mut hasher = Hasher::new(digest)?; + hasher.update(protected)?; + hasher.update(b".")?; + hasher.update(payload)?; + let sig = + openssl::ecdsa::EcdsaSig::sign(hasher.finish()?.as_ref(), key.ec_key()?.as_ref())?; + let r = sig.r().to_vec(); + let s = sig.s().to_vec(); + let mut out = Vec::with_capacity(r.len() + s.len()); + out.extend(r); + out.extend(s); + Ok(out) + } +} diff --git a/src/key.rs b/src/key.rs new file mode 100644 index 00000000..487f65ec --- /dev/null +++ b/src/key.rs @@ -0,0 +1,119 @@ +use std::convert::{TryFrom, TryInto}; + +use openssl::hash::{Hasher, MessageDigest}; +use openssl::pkey::{HasPublic, Id, PKeyRef}; +use serde::Serialize; + +use crate::b64u; +use crate::Error; + +/// An RSA public key. +#[derive(Clone, Debug, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RsaPublicKey { + #[serde(with = "b64u::bytes")] + e: Vec, + #[serde(with = "b64u::bytes")] + n: Vec, +} + +/// An EC public key. +#[derive(Clone, Debug, Serialize)] +#[serde(deny_unknown_fields)] +pub struct EcPublicKey { + crv: &'static str, + #[serde(with = "b64u::bytes")] + x: Vec, + #[serde(with = "b64u::bytes")] + y: Vec, +} + +/// A public key. +/// +/// Internally tagged, so this already contains the 'kty' member. +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "kty")] +pub enum PublicKey { + #[serde(rename = "RSA")] + Rsa(RsaPublicKey), + #[serde(rename = "EC")] + Ec(EcPublicKey), +} + +impl PublicKey { + /// The thumbprint is the b64u encoded sha256sum of the *canonical* json representation. + pub fn thumbprint(&self) -> Result { + let mut hasher = Hasher::new(MessageDigest::sha256())?; + crate::json::to_hash_canonical(&serde_json::to_value(self)?, &mut hasher)?; + Ok(b64u::encode(hasher.finish()?.as_ref())) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct Jwk { + #[serde(rename = "use", skip_serializing_if = "Option::is_none")] + pub usage: Option, + + /// The key data is internally tagged, we can just flatten it. + #[serde(flatten)] + pub key: PublicKey, +} + +impl TryFrom<&PKeyRef

> for Jwk { + type Error = Error; + + fn try_from(key: &PKeyRef

) -> Result { + Ok(Self { + key: key.try_into()?, + usage: None, + }) + } +} + +impl TryFrom<&PKeyRef

> for PublicKey { + type Error = Error; + + fn try_from(key: &PKeyRef

) -> Result { + match key.id() { + Id::RSA => Ok(PublicKey::Rsa(RsaPublicKey::try_from(&key.rsa()?)?)), + Id::EC => Ok(PublicKey::Ec(EcPublicKey::try_from(&key.ec_key()?)?)), + _ => Err(Error::UnsupportedKeyType), + } + } +} + +impl TryFrom<&openssl::rsa::Rsa

> for RsaPublicKey { + type Error = Error; + + fn try_from(key: &openssl::rsa::Rsa

) -> Result { + Ok(RsaPublicKey { + e: key.e().to_vec(), + n: key.n().to_vec(), + }) + } +} + +impl TryFrom<&openssl::ec::EcKey

> for EcPublicKey { + type Error = Error; + + fn try_from(key: &openssl::ec::EcKey

) -> Result { + let group = key.group(); + + if group.curve_name() != Some(openssl::nid::Nid::X9_62_PRIME256V1) { + return Err(Error::UnsupportedGroup); + } + + let mut ctx = openssl::bn::BigNumContext::new()?; + let mut x = openssl::bn::BigNum::new()?; + let mut y = openssl::bn::BigNum::new()?; + let _: () = key + .public_key() + .affine_coordinates_gfp(group, &mut x, &mut y, &mut ctx)?; + + Ok(EcPublicKey { + crv: "P-256", + x: x.to_vec(), + y: y.to_vec(), + }) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..77dc383d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,29 @@ +mod b64u; +mod json; +mod jws; +mod key; +mod request; + +pub mod account; +pub mod authorization; +pub mod directory; +pub mod error; +pub mod order; + +pub use account::Account; +pub use authorization::{Authorization, Challenge}; +pub use directory::Directory; +pub use error::Error; +pub use order::{NewOrder, Order}; +pub use request::{ErrorResponse, Request}; + +/// Header name for nonces. +pub const REPLAY_NONCE: &str = "Replay-Nonce"; + +/// Header name for locations. +pub const LOCATION: &str = "Location"; + +#[cfg(feature = "client")] +pub mod client; +#[cfg(feature = "client")] +pub use client::Client; diff --git a/src/order.rs b/src/order.rs new file mode 100644 index 00000000..f5950943 --- /dev/null +++ b/src/order.rs @@ -0,0 +1,126 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::request::Request; +use crate::Error; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Status { + /// Invalid, used as a place holder for when sending objects as contrary to account creation, + /// the Acme RFC does not require the server to ignore unknown parts of the `Order` object. + New, + + Invalid, + Pending, + Processing, + Ready, + Valid, +} + +impl Default for Status { + fn default() -> Self { + Status::New + } +} + +impl Status { + /// Serde helper + pub fn is_new(&self) -> bool { + *self == Status::New + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "type", content = "value", rename_all = "lowercase")] +pub enum Identifier { + Dns(String), +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderData { + /// The order status. + #[serde(skip_serializing_if = "Status::is_new", default)] + pub status: Status, + + /// This order's expiration date as RFC3339 formatted time string. + pub expires: Option, + + /// List of identifiers to order for the certificate. + pub identifiers: Vec, + + /// An RFC3339 formatted time string. It is up to the user to choose a dev dependency for this + /// shit. + #[serde(skip_serializing_if = "Option::is_none")] + pub not_before: Option, + + /// An RFC3339 formatted time string. It is up to the user to choose a dev dependency for this + /// shit. + #[serde(skip_serializing_if = "Option::is_none")] + pub not_after: Option, + + /// Possible errors in this order. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + + /// List of URL's to authorizations the client needs to complete. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub authorizations: Vec, + + /// URL the final CSR needs to be POSTed to in order to complete the order, once all + /// authorizations have been performed. + #[serde(skip_serializing_if = "Option::is_none")] + pub finalize: Option, + + /// URL at which the issued certificate can be fetched once it is available. + #[serde(skip_serializing_if = "Option::is_none")] + pub certificate: Option, +} + +impl OrderData { + pub fn new() -> Self { + Default::default() + } + + pub fn domain(mut self, domain: String) -> Self { + self.identifiers.push(Identifier::Dns(domain)); + self + } +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Order { + /// Order location URL. + pub location: String, + + /// The order's data object. + pub data: OrderData, +} + +/// Represents a new in-flight order creation. +/// +/// This is created via [`Account::new_order`]. +pub struct NewOrder { + //order: OrderData, + pub request: Option, +} + +impl NewOrder { + pub(crate) fn new(request: Request) -> Self { + Self { + //order, + request: Some(request), + } + } + + /// Deal with the response we got from the server. + pub fn response(self, location_header: String, response_body: &[u8]) -> Result { + Ok(Order { + location: location_header, + data: serde_json::from_slice(response_body) + .map_err(|err| Error::BadOrderData(err.to_string()))?, + }) + } +} diff --git a/src/request.rs b/src/request.rs new file mode 100644 index 00000000..4cd929ab --- /dev/null +++ b/src/request.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; + +pub const JSON_CONTENT_TYPE: &str = "application/jose+json"; + +pub const CREATED: u16 = 201; + +/// A request which should be performed on the ACME provider. +pub struct Request { + pub url: String, + pub method: &'static str, + pub content_type: &'static str, + pub body: String, + + pub expected: u16, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ErrorResponse { + #[serde(rename = "type")] + pub ty: String, + pub detail: Option, + pub subproblems: Option, +}