diff --git a/Cargo.toml b/Cargo.toml index ceae47ef..a394e7dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "proxmox-acme", "proxmox-api-macro", "proxmox-apt", "proxmox-async", diff --git a/proxmox-acme/Cargo.toml b/proxmox-acme/Cargo.toml new file mode 100644 index 00000000..8f8f6e1c --- /dev/null +++ b/proxmox-acme/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "proxmox-acme" +version = "0.5.0" +description = "ACME client library" +authors.workspace = true +license.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true + +exclude = [ "debian" ] + +[dependencies] +base64.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +openssl.workspace = true + +# For the client +native-tls = { workspace = true, optional = true } + +[dependencies.ureq] +optional = true +version = "2.4" +default-features = false +features = [ "native-tls", "gzip" ] + +[features] +default = [] +client = ["ureq", "native-tls"] + +[dev-dependencies] +anyhow.workspace = true diff --git a/proxmox-acme/debian/changelog b/proxmox-acme/debian/changelog new file mode 100644 index 00000000..902c2459 --- /dev/null +++ b/proxmox-acme/debian/changelog @@ -0,0 +1,99 @@ +rust-proxmox-acme (0.5.0) bookworm; urgency=medium + + * add external account binding support + + * add a few more standard fields to Meta + + * update deprecated openssl calls + + * documentation fixups + + * general code improvements and cleanups + + -- Proxmox Support Team Mon, 04 Dec 2023 11:46:26 +0100 + +rust-proxmox-acme-rs (0.4.0) pve; urgency=medium + + * switch from curl to ureq with native-tls + + * bump edition to 2021 + + -- Proxmox Support Team Tue, 01 Feb 2022 10:19:29 +0100 + +rust-proxmox-acme-rs (0.3.2) pve; urgency=medium + + * rebuild with base64 0.13 + + -- Proxmox Support Team Thu, 18 Nov 2021 12:49:25 +0100 + +rust-proxmox-acme-rs (0.3.1) pve; urgency=medium + + * add proxy support + + -- Proxmox Support Team Thu, 18 Nov 2021 09:46:34 +0100 + +rust-proxmox-acme-rs (0.3.0) pve; urgency=medium + + * directory: make metadata optional + + -- Proxmox Support Team Thu, 21 Oct 2021 13:10:27 +0200 + +rust-proxmox-acme-rs (0.2.2-1) pve; urgency=medium + + * improve crate documentation + + * mark `Error` as 'must_use' + + * make status types `Copy` + + * add Client::directory_url() to get the URL without querying the whole + directory + + -- Proxmox Support Team Fri, 07 May 2021 13:53:08 +0200 + +rust-proxmox-acme-rs (0.2.1-1) pve; urgency=medium + + * make revocation workflow accessible without client + + -- Proxmox Support Team Wed, 14 Apr 2021 14:56:49 +0200 + +rust-proxmox-acme-rs (0.2.0-1) pve; urgency=medium + + * add 'status' and 'url' as fixed members to `Challenge` + + * expose some workflow helpers in a more consistentw ay + + * add `util::Csr` for CSR generation + + -- Proxmox Support Team Mon, 12 Apr 2021 13:06:19 +0200 + +rust-proxmox-acme-rs (0.1.4-1) pve; urgency=medium + + * collect extra account fields (such as 'created' from let's encrypt) + in the AccountData struct + + -- Proxmox Support Team Wed, 17 Mar 2021 15:28:09 +0100 + +rust-proxmox-acme-rs (0.1.3-1) pve; urgency=medium + + * fix padding in ecdsa signatures + + -- Proxmox Support Team Wed, 17 Mar 2021 13:34:10 +0100 + +rust-proxmox-acme-rs (0.1.2-1) pve; urgency=medium + + * include Content-length header in requests + + -- Proxmox Support Team Fri, 12 Mar 2021 15:43:01 +0100 + +rust-proxmox-acme-rs (0.1.1-1) pve; urgency=medium + + * make AccountData fields public + + -- Proxmox Support Team Tue, 09 Mar 2021 13:22:55 +0100 + +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/proxmox-acme/debian/control b/proxmox-acme/debian/control new file mode 100644 index 00000000..f30db43b --- /dev/null +++ b/proxmox-acme/debian/control @@ -0,0 +1,93 @@ +Source: rust-proxmox-acme +Section: rust +Priority: optional +Build-Depends: debhelper (>= 12), + dh-cargo (>= 25), + cargo:native , + rustc:native , + libstd-rust-dev , + librust-base64-0.13+default-dev , + librust-openssl-0.10+default-dev , + librust-serde-1+default-dev , + librust-serde-1+derive-dev , + librust-serde-json-1+default-dev +Maintainer: Proxmox Support Team +Standards-Version: 4.6.1 +Vcs-Git: +Vcs-Browser: +Homepage: https://proxmox.com +X-Cargo-Crate: proxmox-acme +Rules-Requires-Root: no + +Package: librust-proxmox-acme-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-base64-0.13+default-dev, + librust-openssl-0.10+default-dev, + librust-serde-1+default-dev, + librust-serde-1+derive-dev, + librust-serde-json-1+default-dev +Suggests: + librust-proxmox-acme+client-dev (= ${binary:Version}), + librust-proxmox-acme+native-tls-dev (= ${binary:Version}), + librust-proxmox-acme+ureq-dev (= ${binary:Version}) +Provides: + librust-proxmox-acme+default-dev (= ${binary:Version}), + librust-proxmox-acme-0-dev (= ${binary:Version}), + librust-proxmox-acme-0+default-dev (= ${binary:Version}), + librust-proxmox-acme-0.5-dev (= ${binary:Version}), + librust-proxmox-acme-0.5+default-dev (= ${binary:Version}), + librust-proxmox-acme-0.5.0-dev (= ${binary:Version}), + librust-proxmox-acme-0.5.0+default-dev (= ${binary:Version}) +Description: ACME client library - Rust source code + This package contains the source for the Rust proxmox-acme crate, packaged by + debcargo for use with cargo and dh-cargo. + +Package: librust-proxmox-acme+client-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-proxmox-acme-dev (= ${binary:Version}), + librust-proxmox-acme+ureq-dev (= ${binary:Version}), + librust-proxmox-acme+native-tls-dev (= ${binary:Version}) +Provides: + librust-proxmox-acme-0+client-dev (= ${binary:Version}), + librust-proxmox-acme-0.5+client-dev (= ${binary:Version}), + librust-proxmox-acme-0.5.0+client-dev (= ${binary:Version}) +Description: ACME client library - feature "client" + This metapackage enables feature "client" for the Rust proxmox-acme crate, by + pulling in any additional dependencies needed by that feature. + +Package: librust-proxmox-acme+native-tls-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-proxmox-acme-dev (= ${binary:Version}), + librust-native-tls-0.2+default-dev +Provides: + librust-proxmox-acme-0+native-tls-dev (= ${binary:Version}), + librust-proxmox-acme-0.5+native-tls-dev (= ${binary:Version}), + librust-proxmox-acme-0.5.0+native-tls-dev (= ${binary:Version}) +Description: ACME client library - feature "native-tls" + This metapackage enables feature "native-tls" for the Rust proxmox-acme crate, + by pulling in any additional dependencies needed by that feature. + +Package: librust-proxmox-acme+ureq-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-proxmox-acme-dev (= ${binary:Version}), + librust-ureq-2+gzip-dev (>= 2.4-~~), + librust-ureq-2+native-tls-dev (>= 2.4-~~) +Provides: + librust-proxmox-acme-0+ureq-dev (= ${binary:Version}), + librust-proxmox-acme-0.5+ureq-dev (= ${binary:Version}), + librust-proxmox-acme-0.5.0+ureq-dev (= ${binary:Version}) +Description: ACME client library - feature "ureq" + This metapackage enables feature "ureq" for the Rust proxmox-acme crate, by + pulling in any additional dependencies needed by that feature. diff --git a/proxmox-acme/debian/copyright b/proxmox-acme/debian/copyright new file mode 100644 index 00000000..477c3058 --- /dev/null +++ b/proxmox-acme/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/proxmox-acme/debian/debcargo.toml b/proxmox-acme/debian/debcargo.toml new file mode 100644 index 00000000..703440fc --- /dev/null +++ b/proxmox-acme/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/proxmox-acme/debian/source/format b/proxmox-acme/debian/source/format new file mode 100644 index 00000000..89ae9db8 --- /dev/null +++ b/proxmox-acme/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/proxmox-acme/src/account.rs b/proxmox-acme/src/account.rs new file mode 100644 index 00000000..9f3af264 --- /dev/null +++ b/proxmox-acme/src/account.rs @@ -0,0 +1,502 @@ +//! ACME Account management and creation. The [`Account`] type also contains most of the ACME API +//! entry point helpers. + +use std::collections::HashMap; +use std::convert::TryFrom; + +use openssl::pkey::{PKey, Private}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::authorization::{Authorization, GetAuthorization}; +use crate::b64u; +use crate::directory::Directory; +use crate::eab::ExternalAccountBinding; +use crate::jws::Jws; +use crate::key::{Jwk, PublicKey}; +use crate::order::{NewOrder, Order, OrderData}; +use crate::request::Request; +use crate::Error; + +/// An ACME Account. +/// +/// This contains the location URL, the account data and the private key for an account. +/// This can directly be serialized via serde to persist the account. +/// +/// In order to register a new account with an ACME provider, see the [`Account::creator`] method. +#[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 { + /// Rebuild an account from its components. + pub fn from_parts(location: String, private_key: String, data: AccountData) -> Self { + Self { + location, + data, + private_key, + } + } + + /// Builds an [`AccountCreator`]. This handles creation of the private key and account data as + /// well as handling the response sent by the server for the registration request. + pub fn creator() -> AccountCreator { + AccountCreator::default() + } + + /// Place a new order. This will build a [`NewOrder`] representing an in flight order creation + /// request. + /// + /// 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. Low level helper. + 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. Low level helper. + 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, + }) + } + + /// Prepare a JSON POST request. + fn post_request_raw_payload( + &self, + url: &str, + nonce: &str, + payload: String, + ) -> 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(), + payload, + )?)?; + + 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)) + } + + /// Prepare a request to update account data. + /// + /// This is a rather low level interface. You should know what you're doing. + pub fn update_account_request( + &self, + nonce: &str, + data: &T, + ) -> Result { + self.post_request(&self.location, nonce, data) + } + + /// Prepare a request to deactivate this account. + pub fn deactivate_account_request(&self, nonce: &str) -> Result { + self.post_request_raw_payload( + &self.location, + nonce, + r#"{"status":"deactivated"}"#.to_string(), + ) + } + + /// Prepare a request to query an Authorization for an Order. + /// + /// Returns `Ok(None)` if `auth_index` is out of out of range. You can query the number of + /// authorizations from via [`Order::authorization_len`] or by manually inspecting its + /// `.data.authorization` vector. + pub fn get_authorization( + &self, + order: &Order, + auth_index: usize, + nonce: &str, + ) -> Result, Error> { + match order.authorization(auth_index) { + None => Ok(None), + Some(url) => Ok(Some(GetAuthorization::new(self.get_request(url, nonce)?))), + } + } + + /// Prepare a request to validate a Challenge from an Authorization. + /// + /// Returns `Ok(None)` if `challenge_index` is out of out of range. The challenge count is + /// available by inspecting the [`Authorization::challenges`] vector. + /// + /// This returns a raw `Request` since validation takes some time and the `Authorization` + /// object has to be re-queried and its `status` inspected. + pub fn validate_challenge( + &self, + authorization: &Authorization, + challenge_index: usize, + nonce: &str, + ) -> Result, Error> { + match authorization.challenges.get(challenge_index) { + None => Ok(None), + Some(challenge) => self + .post_request_raw_payload(&challenge.url, nonce, "{}".to_string()) + .map(Some), + } + } + + /// Prepare a request to revoke a certificate. + /// + /// The certificate can be either PEM or DER formatted. + /// + /// Note that this uses the account's key for authorization. + /// + /// Revocation using a certificate's private key is not yet implemented. + pub fn revoke_certificate( + &self, + certificate: &[u8], + reason: Option, + ) -> Result { + 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 }), + }; + + Ok(CertificateRevocation { + account: self, + data, + }) + } +} + +/// Certificate revocation involves converting the certificate to base64url encoded DER and then +/// embedding it in a json structure. Since we also need a nonce and possibly retry the request if +/// a `BadNonce` error happens, this caches the converted data for efficiency. +pub struct CertificateRevocation<'a> { + account: &'a Account, + data: Value, +} + +impl CertificateRevocation<'_> { + /// Create the revocation request using the specified nonce for the given directory. + pub fn request(&self, directory: &Directory, nonce: &str) -> Result { + self.account + .post_request(&directory.data.revoke_cert, nonce, &self.data) + } +} + +/// Status of an ACME account. +#[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum AccountStatus { + /// This is not part of the ACME API, but a temporary marker for us until the ACME provider + /// tells us the account's real status. + #[serde(rename = "")] + New, + + /// Means the account is valid and can be used. + Valid, + + /// The account has been deactivated by its user and cannot be used anymore. + Deactivated, + + /// The account has been revoked by the server and cannot be used anymore. + Revoked, +} + +impl AccountStatus { + #[inline] + fn new() -> Self { + AccountStatus::New + } + + #[inline] + fn is_new(&self) -> bool { + *self == AccountStatus::New + } +} + +/// ACME Account data. This is the part of the account returned from and possibly sent to the ACME +/// provider. Some fields may be uptdated by the user via a request to the account location, others +/// may not be changed. +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountData { + /// The current account status. + #[serde( + skip_serializing_if = "AccountStatus::is_new", + default = "AccountStatus::new" + )] + pub status: AccountStatus, + + /// URLs to currently pending orders. + #[serde(skip_serializing_if = "Option::is_none")] + pub orders: Option, + + /// The acccount's contact info. + /// + /// This usually contains a `"mailto:"` entry but may also contain some other + /// data if the server accepts it. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub contact: Vec, + + /// Indicated whether the user agreed to the ACME provider's terms of service. + #[serde(skip_serializing_if = "Option::is_none")] + pub terms_of_service_agreed: Option, + + /// External account information. + #[serde(skip_serializing_if = "Option::is_none")] + pub external_account_binding: Option, + + /// This is only used by the client when querying an account. + #[serde(default = "default_true", skip_serializing_if = "is_false")] + pub only_return_existing: bool, + + /// Stores unknown fields if there are any. + #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")] + pub extra: HashMap, +} + +#[inline] +fn default_true() -> bool { + true +} + +#[inline] +fn is_false(b: &bool) -> bool { + !*b +} + +/// Helper to create an account. +/// +/// This is used to generate a private key and set the contact info for the account. Afterwards the +/// creation request can be created via the [`request`](AccountCreator::request()) method, giving +/// it a nonce and a directory. This can be repeated, if necessary, like when the nonce fails. +/// +/// When the server sends a succesful response, it should be passed to the +/// [`response`](AccountCreator::response()) method to finish the creation of an [`Account`] which +/// can then be persisted. +#[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>, + eab_credentials: Option<(String, PKey)>, +} + +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 + } + + /// Set the EAB credentials for the account registration + pub fn set_eab_credentials(mut self, kid: String, hmac_key: String) -> Result { + let hmac_key = PKey::hmac(&base64::decode(hmac_key)?)?; + self.eab_credentials = Some((kid, hmac_key)); + Ok(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`](AccountCreator::response()) will render the account unusable! + pub fn request(&self, directory: &Directory, nonce: &str) -> Result { + let key = self.key.as_deref().ok_or(Error::MissingKey)?; + let url = directory.new_account_url(); + + let external_account_binding = self + .eab_credentials + .as_ref() + .map(|cred| { + ExternalAccountBinding::new(&cred.0, &cred.1, Jwk::try_from(key)?, url.to_string()) + }) + .transpose()?; + + 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, + only_return_existing: false, + extra: HashMap::new(), + }; + + 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()`](AccountCreator::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("PEM key contained illegal non-utf-8 characters".to_string()) + })?; + + 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/proxmox-acme/src/authorization.rs b/proxmox-acme/src/authorization.rs new file mode 100644 index 00000000..fee3614d --- /dev/null +++ b/proxmox-acme/src/authorization.rs @@ -0,0 +1,162 @@ +//! Authorization and Challenge data. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::order::Identifier; +use crate::request::Request; +use crate::Error; + +/// Status of an [`Authorization`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Status { + /// The authorization was deactivated by the client. + Deactivated, + + /// The authorization expired. + Expired, + + /// The authorization failed and is now invalid. + Invalid, + + /// Validation is pending. + Pending, + + /// The authorization was revoked by the server. + Revoked, + + /// The identifier is authorized. + Valid, +} + +impl Status { + /// Convenience method to check if the status is 'pending'. + #[inline] + pub fn is_pending(self) -> bool { + self == Status::Pending + } + + /// Convenience method to check if the status is 'valid'. + #[inline] + pub fn is_valid(self) -> bool { + self == Status::Valid + } +} + +/// Represents an authorization state for an order. The user is expected to pick a challenge, +/// execute it, and the request validation for it. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Authorization { + /// The identifier (usually domain name) this authorization is for. + pub identifier: Identifier, + + /// The current status of this authorization entry. + pub status: Status, + + /// Expiration date for the authorization. + #[serde(skip_serializing_if = "Option::is_none")] + pub expires: Option, + + /// List of challenges which can be used to complete this authorization. + pub challenges: Vec, + + /// The authorization is for a wildcard domain. + #[serde(default, skip_serializing_if = "is_false")] + pub wildcard: bool, +} + +/// The state of a challenge. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ChallengeStatus { + /// The challenge is pending and has not been validated yet. + Pending, + + /// The valiation is in progress. + Processing, + + /// The challenge was successfully validated. + Valid, + + /// Validation of this challenge failed. + Invalid, +} + +impl ChallengeStatus { + /// Convenience method to check if the status is 'pending'. + #[inline] + pub fn is_pending(self) -> bool { + self == ChallengeStatus::Pending + } + + /// Convenience method to check if the status is 'valid'. + #[inline] + pub fn is_valid(self) -> bool { + self == ChallengeStatus::Valid + } +} + +/// A challenge object contains information on how to complete an authorization for an order. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Challenge { + /// The challenge type (such as `"dns-01"`). + #[serde(rename = "type")] + pub ty: String, + + /// The current challenge status. + pub status: ChallengeStatus, + + /// The URL used to post to in order to begin the validation for this challenge. + pub url: String, + + /// Contains the remaining fields of the Challenge object, such as the `token`. + #[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 +} + +/// Represents an in-flight query for an authorization. +/// +/// This is created via [`Account::get_authorization`](crate::Account::get_authorization()). +pub struct GetAuthorization { + //order: OrderData, + /// The request to send to the ACME provider. This is wrapped in an option in order to allow + /// moving it out instead of copying the contents. + /// + /// When generated via [`Account::get_authorization`](crate::Account::get_authorization()), + /// this is guaranteed to be `Some`. + /// + /// The response should be passed to the the [`response`](GetAuthorization::response()) method. + pub request: Option, +} + +impl GetAuthorization { + pub(crate) fn new(request: Request) -> Self { + Self { + request: Some(request), + } + } + + /// Deal with the response we got from the server. + pub fn response(self, response_body: &[u8]) -> Result { + Ok(serde_json::from_slice(response_body)?) + } +} diff --git a/proxmox-acme/src/b64u.rs b/proxmox-acme/src/b64u.rs new file mode 100644 index 00000000..a4f8ce0a --- /dev/null +++ b/proxmox-acme/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/proxmox-acme/src/client.rs b/proxmox-acme/src/client.rs new file mode 100644 index 00000000..53f2688a --- /dev/null +++ b/proxmox-acme/src/client.rs @@ -0,0 +1,614 @@ +//! A blocking higher-level ACME client implementation using 'curl'. + +use std::io::Read; +use std::sync::Arc; + +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 { + /// The raw HTTP response body as a byte vector. + pub body: Vec, + + /// The http status code. + pub status: u16, + + /// The headers relevant to the ACME protocol. + 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 { + /// The 'Location' header usually encodes the URL where an account or order can be queried from + /// after they were created. + pub location: Option, + nonce: Option, +} + +struct Inner { + agent: Option, + nonce: Option, + proxy: Option, +} + +impl Inner { + fn agent(&mut self) -> Result<&mut ureq::Agent, Error> { + if self.agent.is_none() { + let connector = Arc::new( + native_tls::TlsConnector::new() + .map_err(|err| format_err!("failed to create tls connector: {}", err))?, + ); + + let mut builder = ureq::AgentBuilder::new().tls_connector(connector); + + if let Some(proxy) = self.proxy.as_deref() { + builder = builder.proxy( + ureq::Proxy::new(proxy) + .map_err(|err| format_err!("failed to set proxy: {}", err))?, + ); + } + + self.agent = Some(builder.build()); + } + + Ok(self.agent.as_mut().unwrap()) + } + + fn new() -> Self { + Self { + agent: None, + nonce: None, + proxy: None, + } + } + + fn execute( + &mut self, + method: &[u8], + url: &str, + request_body: Option<(&str, &[u8])>, // content-type and body + ) -> Result { + let agent = self.agent()?; + let req = match method { + b"POST" => agent.post(url), + b"GET" => agent.get(url), + b"HEAD" => agent.head(url), + other => bail!("invalid http method: {:?}", other), + }; + + let response = if let Some((content_type, body)) = request_body { + req.set("Content-Type", content_type) + .set("Content-Length", &body.len().to_string()) + .send_bytes(body) + } else { + req.call() + } + .map_err(|err| format_err!("http request failed: {}", err))?; + + let mut headers = Headers::default(); + if let Some(value) = response.header(crate::LOCATION) { + headers.location = Some(value.to_owned()); + } + + if let Some(value) = response.header(crate::REPLAY_NONCE) { + headers.nonce = Some(value.to_owned()); + } + + let status = response.status(); + + let mut body = Vec::new(); + response + .into_reader() + .take(16 * 1024 * 1024) // arbitrary limit + .read_to_end(&mut body) + .map_err(|err| format_err!("failed to read response body: {}", err))?; + + Ok(HttpResponse { + status, + headers, + body, + }) + } + + pub fn set_proxy(&mut self, proxy: String) { + self.proxy = Some(proxy); + self.agent = None; + } + + /// Low-level API to run an API request. This automatically updates the current nonce! + fn run_request(&mut self, request: Request) -> Result { + let body = if request.body.is_empty() { + None + } else { + Some((request.content_type, request.body.as_bytes())) + }; + + 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, + } + } + + /// Get the directory URL without querying the `Directory` structure. + /// + /// The difference to [`directory`](Client::directory()) is that this does not + /// attempt to fetch the directory data from the ACME server. + pub fn directory_url(&self) -> &str { + &self.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, + eab_creds: Option<(String, String)>, + ) -> Result<&Account, Error> { + let mut account = Account::creator() + .set_contacts(contact) + .agree_to_tos(tos_agreed); + if let Some((eab_kid, eab_hmac_key)) = eab_creds { + account = account.set_eab_credentials(eab_kid, eab_hmac_key)?; + } + let account = if let Some(bits) = rsa_bits { + account.generate_rsa_key(bits)? + } else { + account.generate_ec_key()? + }; + + self.register_account(account) + } + + /// Register an ACME account. + /// + /// This uses an [`AccountCreator`](crate::account::AccountCreator) since it may need to build + /// the request multiple times in case the we get a `BadNonce` error. + 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), + } + }; + + 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), + }; + + 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), + }; + + 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 { + 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 { + 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), + } + } + } + + /// 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), + } + } + } + + /// Request challenge validation. Afterwards, the challenge should be polled. + pub fn request_challenge_validation(&mut self, url: &str) -> Result { + 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 revocation = account.revoke_certificate(certificate, reason)?; + + 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 = revocation.request(directory, nonce)?; + match self.inner.run_request(request) { + Ok(_response) => return Ok(()), + Err(err) if err.is_bad_nonce() => continue, + Err(err) => return Err(err), + } + } + } + + /// Set a proxy + pub fn set_proxy(&mut self, proxy: String) { + self.inner.set_proxy(proxy) + } +} + +/// 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/proxmox-acme/src/directory.rs b/proxmox-acme/src/directory.rs new file mode 100644 index 00000000..ed8203f9 --- /dev/null +++ b/proxmox-acme/src/directory.rs @@ -0,0 +1,107 @@ +//! ACME Directory information. + +use serde::{Deserialize, Serialize}; + +/// An ACME Directory. This contains the base URL and the directory data as received via a `GET` +/// request to the URL. +pub struct Directory { + /// The main entry point URL to the ACME directory. + pub url: String, + + /// The json structure received via a `GET` request to the directory URL. This contains the + /// URLs for various API entry points. + pub data: DirectoryData, +} + +/// The ACME Directory object structure. +/// +/// The data in here is typically not relevant to the user of this crate. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryData { + /// The entry point to create a new account. + pub new_account: String, + + /// The entry point to retrieve a new nonce, should be used with a `HEAD` request. + pub new_nonce: String, + + /// URL to post new orders to. + pub new_order: String, + + /// URL to use for certificate revocation. + pub revoke_cert: String, + + /// Account key rollover URL. + pub key_change: String, + + /// Metadata object, for additional information which aren't directly part of the API + /// itself, such as the terms of service. + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +/// The directory's "meta" object. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Meta { + /// The terms of service. This is typically in the form of an URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub terms_of_service: Option, + + /// Flag indicating if EAB is required, None is equivalent to false + #[serde(skip_serializing_if = "Option::is_none")] + pub external_account_required: Option, + + /// Website with information about the ACME Server + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, + + /// List of hostnames used by the CA, intended for the use with caa dns records + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub caa_identities: Vec, +} + +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> { + match &self.data.meta { + Some(meta) => meta.terms_of_service.as_deref(), + None => None, + } + } + + /// Get if external account binding is required + pub fn external_account_binding_required(&self) -> bool { + matches!( + &self.data.meta, + Some(Meta { + external_account_required: Some(true), + .. + }) + ) + } + + /// 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. + pub fn meta(&self) -> Option<&Meta> { + self.data.meta.as_ref() + } +} diff --git a/proxmox-acme/src/eab.rs b/proxmox-acme/src/eab.rs new file mode 100644 index 00000000..a4c06424 --- /dev/null +++ b/proxmox-acme/src/eab.rs @@ -0,0 +1,66 @@ +use openssl::hash::MessageDigest; +use openssl::pkey::{HasPrivate, PKeyRef}; +use openssl::sign::Signer; +use serde::{Deserialize, Serialize}; + +use crate::key::Jwk; +use crate::{b64u, Error}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Protected { + alg: &'static str, + url: String, + kid: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ExternalAccountBinding { + protected: String, + payload: String, + signature: String, +} + +impl ExternalAccountBinding { + pub fn new

( + eab_kid: &str, + eab_hmac_key: &PKeyRef

, + jwk: Jwk, + url: String, + ) -> Result + where + P: HasPrivate, + { + let protected = Protected { + alg: "HS256", + kid: eab_kid.to_string(), + url, + }; + let payload = b64u::encode(serde_json::to_string(&jwk)?.as_bytes()); + let protected_data = b64u::encode(serde_json::to_string(&protected)?.as_bytes()); + let signature = { + let protected = protected_data.as_bytes(); + let payload = payload.as_bytes(); + Self::sign_hmac(eab_hmac_key, protected, payload)? + }; + + let signature = b64u::encode(&signature); + Ok(ExternalAccountBinding { + protected: protected_data, + payload, + signature, + }) + } + + fn sign_hmac

(key: &PKeyRef

, protected: &[u8], payload: &[u8]) -> Result, Error> + where + P: HasPrivate, + { + let mut signer = Signer::new(MessageDigest::sha256(), key)?; + signer.update(protected)?; + signer.update(b".")?; + signer.update(payload)?; + Ok(signer.sign_to_vec()?) + } +} diff --git a/proxmox-acme/src/error.rs b/proxmox-acme/src/error.rs new file mode 100644 index 00000000..59da3ea1 --- /dev/null +++ b/proxmox-acme/src/error.rs @@ -0,0 +1,154 @@ +//! The `Error` type and some ACME error constants for reference. + +use std::fmt; + +use openssl::error::ErrorStack as SslErrorStack; + +/// The ACME error string for a "bad nonce" error. +pub const BAD_NONCE: &str = "urn:ietf:params:acme:error:badNonce"; + +/// The ACME error string for a "user action required" error. +pub const USER_ACTION_REQUIRED: &str = "urn:ietf:params:acme:error:userActionRequired"; + +/// Error types returned by this crate. +#[derive(Debug)] +#[must_use = "unused errors have no effect"] +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. + RawSsl(SslErrorStack), + + /// An openssl error occurred during a crypto operation. + /// With some textual context. + Ssl(&'static str, SslErrorStack), + + /// An otherwise uncaught serde error happened. + Json(serde_json::Error), + + /// Failed to parse + BadBase64(base64::DecodeError), + + /// 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 ureq/network errors end up. + /// This is usually a `ureq::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 `ureq` end up. + Client(String), + + /// A non-openssl error occurred while building data for the CSR. + Csr(String), +} + +impl Error { + /// Create an `Error` from a custom text. + 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::RawSsl(err) => fmt::Display::fmt(err, f), + Error::Ssl(context, err) => { + write!(f, "{}: {}", context, err) + } + 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), + Error::Csr(err) => fmt::Display::fmt(err, f), + Error::BadBase64(err) => fmt::Display::fmt(err, f), + } + } +} + +impl From for Error { + fn from(e: SslErrorStack) -> Self { + Error::RawSsl(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) + } +} + +impl From for Error { + fn from(e: base64::DecodeError) -> Self { + Error::BadBase64(e) + } +} diff --git a/proxmox-acme/src/json.rs b/proxmox-acme/src/json.rs new file mode 100644 index 00000000..e192d679 --- /dev/null +++ b/proxmox-acme/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/proxmox-acme/src/jws.rs b/proxmox-acme/src/jws.rs new file mode 100644 index 00000000..b867f714 --- /dev/null +++ b/proxmox-acme/src/jws.rs @@ -0,0 +1,168 @@ +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, ec_order_bytes): (MessageDigest, usize) = match &pubkey { + PublicKey::Rsa(_) => (Self::prepare_rsa(key, &mut protected), 0), + 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, ec_order_bytes, prot, payload), + }? + }; + + let signature = b64u::encode(&signature); + + Ok(Jws { + protected: protected_data, + payload, + signature, + }) + } + + fn prepare_rsa

(_key: &PKeyRef

, protected: &mut Protected) -> MessageDigest + where + P: HasPrivate, + { + protected.alg = "RS256"; + MessageDigest::sha256() + } + + /// Returns the digest and the size of the two signature components 'r' and 's'. + fn prepare_ec

(_key: &PKeyRef

, protected: &mut Protected) -> (MessageDigest, usize) + where + P: HasPrivate, + { + // Note: if we support >256 bit keys we'll want to also support using ES512 here probably + protected.alg = "ES256"; + // 'r' and 's' are each 256 bit numbers: + (MessageDigest::sha256(), 32) + } + + 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, + ec_order_bytes: usize, + 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(ec_order_bytes * 2); + out.extend(std::iter::repeat(0u8).take(ec_order_bytes - r.len())); + out.extend(r); + out.extend(std::iter::repeat(0u8).take(ec_order_bytes - s.len())); + out.extend(s); + Ok(out) + } +} diff --git a/proxmox-acme/src/key.rs b/proxmox-acme/src/key.rs new file mode 100644 index 00000000..5dbc5460 --- /dev/null +++ b/proxmox-acme/src/key.rs @@ -0,0 +1,129 @@ +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()?; + key.public_key() + .affine_coordinates(group, &mut x, &mut y, &mut ctx)?; + + Ok(EcPublicKey { + crv: "P-256", + x: x.to_vec(), + y: y.to_vec(), + }) + } +} + +#[test] +fn test_key_conversion() -> Result<(), Error> { + let key = openssl::ec::EcKey::generate( + openssl::ec::EcGroup::from_curve_name(openssl::nid::Nid::X9_62_PRIME256V1)?.as_ref(), + )?; + + let _ = EcPublicKey::try_from(&key).expect("failed to jsonify ec key"); + + Ok(()) +} diff --git a/proxmox-acme/src/lib.rs b/proxmox-acme/src/lib.rs new file mode 100644 index 00000000..98ad04ec --- /dev/null +++ b/proxmox-acme/src/lib.rs @@ -0,0 +1,61 @@ +//! ACME protocol helper. +//! +//! This is supposed to implement the low level parts of the ACME protocol, providing an [`Account`] +//! and some other helper types which allow interacting with an ACME server by implementing methods +//! which create [`Request`]s the user can then combine with a nonce and send to the the ACME +//! server using whatever http client they choose. +//! +//! This is a rather low level crate, and while it provides an optional synchronous client using +//! curl (for simplicity), users should have basic understanding of the ACME API in order to +//! implement a client using this. +//! +//! The [`Account`] helper supports RSA and ECC keys and provides most of the API methods. + +#![deny(missing_docs)] + +mod b64u; +mod eab; +mod json; +mod jws; +mod key; +mod request; + +pub mod account; +pub mod authorization; +pub mod directory; +pub mod error; +pub mod order; +pub mod util; + +#[doc(inline)] +pub use account::Account; + +#[doc(inline)] +pub use authorization::{Authorization, Challenge}; + +#[doc(inline)] +pub use directory::Directory; + +#[doc(inline)] +pub use error::Error; + +#[doc(inline)] +pub use order::Order; + +#[doc(inline)] +pub use request::Request; + +// we don't inline these: +pub use order::NewOrder; +pub use request::ErrorResponse; + +/// 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/proxmox-acme/src/order.rs b/proxmox-acme/src/order.rs new file mode 100644 index 00000000..404d4ae7 --- /dev/null +++ b/proxmox-acme/src/order.rs @@ -0,0 +1,179 @@ +//! ACME Orders data and identifiers. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::request::Request; +use crate::Error; + +/// Status of an [`Order`]. +#[derive(Clone, Copy, 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, + + /// Authorization failed and it is now invalid. + Invalid, + + /// The authorization is pending and the user should look through its challenges. + /// + /// This is the initial state of a new authorization. + Pending, + + /// The ACME provider is processing an authorization validation. + Processing, + + /// The requirements for the order have been met and it may be finalized. + Ready, + + /// The certificate has been issued and can be downloaded from the URL provided in the + /// [`Order`]'s `certificate` field. + Valid, +} + +impl Default for Status { + fn default() -> Self { + Status::New + } +} + +impl Status { + /// Serde helper + fn is_new(&self) -> bool { + *self == Status::New + } + + /// Convenience method to check if the status is 'pending'. + #[inline] + pub fn is_pending(self) -> bool { + self == Status::Pending + } + + /// Convenience method to check if the status is 'valid'. + #[inline] + pub fn is_valid(self) -> bool { + self == Status::Valid + } +} + +/// An identifier used for a certificate request. +/// +/// Currently only supports DNS name identifiers. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "type", content = "value", rename_all = "lowercase")] +pub enum Identifier { + /// A DNS identifier is used to request a domain name to be added to a certificate. + Dns(String), +} + +/// This contains the order data sent to and received from the ACME server. +/// +/// This is typically filled with a set of domains and then issued as a new-order request via [`Account::new_order`](crate::Account::new_order). +#[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. + #[serde(skip_serializing_if = "Option::is_none")] + 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 { + /// Initialize an empty order object. + pub fn new() -> Self { + Default::default() + } + + /// Builder-style method to add a domain identifier to the data. + pub fn domain(mut self, domain: String) -> Self { + self.identifiers.push(Identifier::Dns(domain)); + self + } +} + +/// Represents an order for a new certificate. This combines the order's own location (URL) with +/// the [`OrderData`] received from the ACME server. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Order { + /// Order location URL. + pub location: String, + + /// The order's data object. + pub data: OrderData, +} + +impl Order { + /// Get an authorization URL (or `None` if the index is out of range). + pub fn authorization(&self, index: usize) -> Option<&str> { + Some(self.data.authorizations.get(index)?) + } + + /// Get the number of authorizations in this object. + pub fn authorization_len(&self) -> usize { + self.data.authorizations.len() + } +} + +/// Represents a new in-flight order creation. +/// +/// This is created via [`Account::new_order`](crate::Account::new_order()). +pub struct NewOrder { + //order: OrderData, + /// The request to execute to place the order. When creating a [`NewOrder`] via + /// [`Account::new_order`](crate::Account::new_order) this is guaranteed to be `Some`. + 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/proxmox-acme/src/request.rs b/proxmox-acme/src/request.rs new file mode 100644 index 00000000..78a90913 --- /dev/null +++ b/proxmox-acme/src/request.rs @@ -0,0 +1,42 @@ +use serde::Deserialize; + +pub(crate) const JSON_CONTENT_TYPE: &str = "application/jose+json"; +pub(crate) const CREATED: u16 = 201; + +/// A request which should be performed on the ACME provider. +pub struct Request { + /// The complete URL to send the request to. + pub url: String, + + /// The HTTP method name to use. + pub method: &'static str, + + /// The `Content-Type` header to pass along. + pub content_type: &'static str, + + /// The body to pass along with request, or an empty string. + pub body: String, + + /// The expected status code a compliant ACME provider will return on success. + pub expected: u16, +} + +/// An ACME error response contains a specially formatted type string, and can optionally +/// contain textual details and a set of sub problems. +#[derive(Clone, Debug, Deserialize)] +pub struct ErrorResponse { + /// The ACME error type string. + /// + /// Most of the time we're only interested in the "bad nonce" or "user action required" + /// errors. When an [`Error`](crate::Error) is built from this error response, it will map + /// to the corresponding enum values (eg. [`Error::BadNonce`](crate::Error::BadNonce)). + #[serde(rename = "type")] + pub ty: String, + + /// A textual detail string optionally provided by the ACME provider to inform the user more + /// verbosely about why the error occurred. + pub detail: Option, + + /// Additional json data containing information as to why the error occurred. + pub subproblems: Option, +} diff --git a/proxmox-acme/src/util.rs b/proxmox-acme/src/util.rs new file mode 100644 index 00000000..57acf852 --- /dev/null +++ b/proxmox-acme/src/util.rs @@ -0,0 +1,85 @@ +//! Certificate utility methods for convenience (such as CSR generation). + +use std::collections::HashMap; + +use openssl::hash::MessageDigest; +use openssl::nid::Nid; +use openssl::pkey::PKey; +use openssl::rsa::Rsa; +use openssl::x509::{self, X509Name, X509Req}; + +use crate::Error; + +/// A certificate signing request. +pub struct Csr { + /// DER encoded certificate request. + pub data: Vec, + + /// PEM formatted PKCS#8 private key. + pub private_key_pem: Vec, +} + +impl Csr { + /// Generate a CSR in DER format with a PEM formatted PKCS8 private key. + /// + /// The `identifiers` should be a list of domains. The `attributes` should have standard names + /// recognized by openssl. + pub fn generate( + identifiers: &[impl AsRef], + attributes: &HashMap, + ) -> Result { + if identifiers.is_empty() { + return Err(Error::Csr("cannot generate empty CSR".to_string())); + } + + let private_key = Rsa::generate(4096) + .and_then(PKey::from_rsa) + .map_err(|err| Error::Ssl("failed to generate RSA key: {}", err))?; + + let private_key_pem = private_key + .private_key_to_pem_pkcs8() + .map_err(|err| Error::Ssl("failed to format private key as PEM pkcs8: {}", err))?; + + let mut name = X509Name::builder()?; + if !attributes.contains_key("CN") { + name.append_entry_by_nid(Nid::COMMONNAME, identifiers[0].as_ref())?; + } + for (key, value) in attributes { + name.append_entry_by_text(key, value)?; + } + let name = name.build(); + + let mut csr = X509Req::builder()?; + csr.set_subject_name(&name)?; + csr.set_pubkey(&private_key)?; + + let context = csr.x509v3_context(None); + let mut ext = openssl::stack::Stack::new()?; + ext.push(x509::extension::BasicConstraints::new().build()?)?; + ext.push( + x509::extension::KeyUsage::new() + .digital_signature() + .key_encipherment() + .build()?, + )?; + ext.push( + x509::extension::ExtendedKeyUsage::new() + .server_auth() + .client_auth() + .build()?, + )?; + let mut san = x509::extension::SubjectAlternativeName::new(); + for dns in identifiers { + san.dns(dns.as_ref()); + } + ext.push({ san }.build(&context)?)?; + csr.add_extensions(&ext)?; + + csr.sign(&private_key, MessageDigest::sha256())?; + + Ok(Self { + data: csr.build().to_der()?, + private_key_pem, + }) + } +}