diff --git a/CHANGELOG.md b/CHANGELOG.md index 59da79b..4f5ffba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- The `token` challenge hook variable has been renamed `file_name`. +- The logs has been purged from many useless debug and trace entries. + +### Removed +- The DER storage format has been removed. + + ## [0.2.1] - 2019-03-30 ### Changed diff --git a/acmed/Cargo.toml b/acmed/Cargo.toml index 7b22516..ea9f57b 100644 --- a/acmed/Cargo.toml +++ b/acmed/Cargo.toml @@ -9,21 +9,26 @@ repository = "https://github.com/breard-r/acmed" readme = "README.md" license = "MIT OR Apache-2.0" include = ["src/**/*", "Cargo.toml", "LICENSE-*.txt"] +build = "build.rs" [dependencies] -acme-lib = "0.5" +base64 = "0.10" clap = "2.32" -daemonize = "0.3" +daemonize = "0.4" env_logger = "0.6" handlebars = "2.0.0-beta.1" +http_req = "0.4" log = "0.4" openssl = "0.10" -pem = "0.5" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" syslog = "4.0" time = "0.1" toml = "0.5" -x509-parser = "0.4" + +[build-dependencies] +serde = { version = "1.0", features = ["derive"] } +toml = "0.5" [target.'cfg(unix)'.dependencies] nix = "0.13" diff --git a/acmed/build.rs b/acmed/build.rs new file mode 100644 index 0000000..2902dd1 --- /dev/null +++ b/acmed/build.rs @@ -0,0 +1,78 @@ +extern crate serde; +extern crate toml; + +use serde::Deserialize; +use std::env; +use std::fs::File; +use std::io::prelude::*; +use std::path::PathBuf; + +macro_rules! set_rustc_env_var { + ($name: expr, $value: expr) => {{ + println!("cargo:rustc-env={}={}", $name, $value); + }}; +} + +#[derive(Deserialize)] +pub struct Lock { + package: Vec, +} + +#[derive(Deserialize)] +struct Package { + name: String, + version: String, +} + +struct Error; + +impl From for Error { + fn from(_error: std::io::Error) -> Self { + Error {} + } +} + +impl From for Error { + fn from(_error: toml::de::Error) -> Self { + Error {} + } +} + +fn get_lock() -> Result { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.pop(); + path.push("Cargo.lock"); + let mut file = File::open(path)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + let ret: Lock = toml::from_str(&contents)?; + Ok(ret) +} + +fn set_lock() { + let lock = match get_lock() { + Ok(l) => l, + Err(_) => { + return; + } + }; + for p in lock.package.iter() { + if p.name == "http_req" { + let agent = format!("{}/{}", p.name, p.version); + set_rustc_env_var!("ACMED_HTTP_LIB_AGENT", agent); + return; + } + } +} + +fn set_target() { + match env::var("TARGET") { + Ok(target) => set_rustc_env_var!("ACMED_TARGET", target), + Err(_) => {} + }; +} + +fn main() { + set_target(); + set_lock(); +} diff --git a/acmed/src/acme_proto.rs b/acmed/src/acme_proto.rs new file mode 100644 index 0000000..d03df6b --- /dev/null +++ b/acmed/src/acme_proto.rs @@ -0,0 +1,212 @@ +use crate::acme_proto::account::AccountManager; +use crate::acme_proto::jws::encode_kid; +use crate::acme_proto::structs::{ + Authorization, AuthorizationStatus, NewOrder, Order, OrderStatus, +}; +use crate::certificate::Certificate; +use crate::error::Error; +use crate::storage; +use log::info; +use std::{fmt, thread, time}; + +mod account; +mod certificate; +mod http; +pub mod jws; +mod structs; + +#[derive(Clone, Debug, PartialEq)] +pub enum Challenge { + Http01, + Dns01, +} + +impl Challenge { + pub fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "http-01" => Ok(Challenge::Http01), + "dns-01" => Ok(Challenge::Dns01), + _ => Err(format!("{}: unknown challenge.", s).into()), + } + } +} + +impl fmt::Display for Challenge { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = match self { + Challenge::Http01 => "http-01", + Challenge::Dns01 => "dns-01", + }; + write!(f, "{}", s) + } +} + +impl PartialEq for Challenge { + fn eq(&self, other: &structs::Challenge) -> bool { + match (self, other) { + (Challenge::Http01, structs::Challenge::Http01(_)) => true, + (Challenge::Dns01, structs::Challenge::Dns01(_)) => true, + _ => false, + } + } +} + +fn pool( + account: &AccountManager, + url: &str, + nonce: &str, + get_fn: F, + break_fn: G, +) -> Result<(T, String), Error> +where + F: Fn(&str, &[u8]) -> Result<(T, String), Error>, + G: Fn(&T) -> bool, +{ + let mut nonce: String = nonce.to_string(); + for _ in 0..crate::DEFAULT_POOL_NB_TRIES { + thread::sleep(time::Duration::from_secs(crate::DEFAULT_POOL_WAIT_SEC)); + let data = encode_kid(&account.priv_key, &account.account_url, b"", url, &nonce)?; + let (obj, new_nonce) = get_fn(url, data.as_bytes())?; + if break_fn(&obj) { + return Ok((obj, new_nonce)); + } + nonce = new_nonce; + } + let msg = format!("Pooling failed for {}", url); + Err(msg.into()) +} + +pub fn request_certificate(cert: &Certificate) -> Result<(), Error> { + // 1. Get the directory + let directory = http::get_directory(&cert.remote_url)?; + + // 2. Get a first nonce + let nonce = http::get_nonce(&directory.new_nonce)?; + + // 3. Get or create the account + let (account, nonce) = AccountManager::new(cert, &directory, &nonce)?; + + // 4. Create a new order + let new_order = NewOrder::new(&cert.domains); + let new_order = serde_json::to_string(&new_order)?; + let new_order = encode_kid( + &account.priv_key, + &account.account_url, + new_order.as_bytes(), + &directory.new_order, + &nonce, + )?; + let (order, order_url, mut nonce) = + http::get_obj_loc::(&directory.new_order, new_order.as_bytes())?; + + // 5. Get all the required authorizations + for auth_url in order.authorizations.iter() { + let auth_data = encode_kid( + &account.priv_key, + &account.account_url, + b"", + &auth_url, + &nonce, + )?; + let (auth, new_nonce) = http::get_obj::(&auth_url, auth_data.as_bytes())?; + nonce = new_nonce; + + if auth.status == AuthorizationStatus::Valid { + continue; + } + if auth.status != AuthorizationStatus::Pending { + let msg = format!( + "{}: authorization status is {}", + auth.identifier, auth.status + ); + return Err(msg.into()); + } + + // 6. For each authorization, fetch the associated challenges + for challenge in auth.challenges.iter() { + if cert.challenge == *challenge { + let proof = challenge.get_proof(&account.priv_key)?; + let file_name = challenge.get_file_name(); + let domain = auth.identifier.value.to_owned(); + + // 7. Call the challenge hook in order to complete it + cert.call_challenge_hooks(&file_name, &proof, &domain)?; + + // 8. Tell the server the challenge has been completed + let chall_url = challenge.get_url(); + let chall_resp_data = encode_kid( + &account.priv_key, + &account.account_url, + b"{}", + &chall_url, + &nonce, + )?; + let new_nonce = + http::post_challenge_response(&chall_url, chall_resp_data.as_bytes())?; + nonce = new_nonce; + } + } + + // 9. Pool the authorization in order to see whether or not it is valid + let (_, new_nonce) = pool( + &account, + &auth_url, + &nonce, + |u, d| http::get_obj::(u, d), + |a| a.status == AuthorizationStatus::Valid, + )?; + nonce = new_nonce; + } + + // 10. Pool the order in order to see whether or not it is ready + let (order, nonce) = pool( + &account, + &order_url, + &nonce, + |u, d| http::get_obj::(u, d), + |a| a.status == OrderStatus::Ready, + )?; + + // 11. Finalize the order by sending the CSR + let (priv_key, pub_key) = certificate::get_key_pair(cert)?; + let csr = certificate::generate_csr(cert, &priv_key, &pub_key)?; + let csr_data = encode_kid( + &account.priv_key, + &account.account_url, + csr.as_bytes(), + &order.finalize, + &nonce, + )?; + let (_, nonce) = http::get_obj::(&order.finalize, &csr_data.as_bytes())?; + + // 12. Pool the order in order to see whether or not it is valid + let (order, nonce) = pool( + &account, + &order_url, + &nonce, + |u, d| http::get_obj::(u, d), + |a| a.status == OrderStatus::Valid, + )?; + + // 13. Download the certificate + // TODO: implement + let crt_url = order + .certificate + .ok_or_else(|| Error::from("No certificate available for download."))?; + let crt_data = encode_kid( + &account.priv_key, + &account.account_url, + b"", + &crt_url, + &nonce, + )?; + let (crt, _) = http::get_certificate(&crt_url, &crt_data.as_bytes())?; + storage::write_certificate(cert, &crt.as_bytes())?; + + info!("Certificate renewed for {}", cert.domains.join(", ")); + Ok(()) +} + +pub fn b64_encode>(input: &T) -> String { + base64::encode_config(input, base64::URL_SAFE_NO_PAD) +} diff --git a/acmed/src/acme_proto/account.rs b/acmed/src/acme_proto/account.rs new file mode 100644 index 0000000..c097ba7 --- /dev/null +++ b/acmed/src/acme_proto/account.rs @@ -0,0 +1,54 @@ +use crate::acme_proto::http; +use crate::acme_proto::jws::algorithms::SignatureAlgorithm; +use crate::acme_proto::jws::encode_jwk; +use crate::acme_proto::structs::{Account, AccountResponse, Directory}; +use crate::certificate::Certificate; +use crate::error::Error; +use crate::storage; +use openssl::pkey::{PKey, Private, Public}; +use std::str::FromStr; + +pub struct AccountManager { + pub priv_key: PKey, + pub pub_key: PKey, + pub account_url: String, + pub orders_url: String, +} + +impl AccountManager { + pub fn new( + cert: &Certificate, + directory: &Directory, + nonce: &str, + ) -> Result<(Self, String), Error> { + // TODO: store the key id (account url) + let (priv_key, pub_key) = if storage::account_files_exists(cert) { + // TODO: check if the keys are suitable for the specified signature algorithm + // and, if not, initiate a key rollover. + ( + storage::get_account_priv_key(cert)?, + storage::get_account_pub_key(cert)?, + ) + } else { + // TODO: allow to change the signature algo + let sign_alg = SignatureAlgorithm::from_str(crate::DEFAULT_JWS_SIGN_ALGO)?; + let (priv_key, pub_key) = sign_alg.gen_key_pair()?; + storage::set_account_priv_key(cert, &priv_key)?; + storage::set_account_pub_key(cert, &pub_key)?; + (priv_key, pub_key) + }; + let account = Account::new(&[cert.email.to_owned()]); + let account = serde_json::to_string(&account)?; + let data = encode_jwk(&priv_key, account.as_bytes(), &directory.new_account, nonce)?; + let (acc_rep, account_url, nonce) = + http::get_obj_loc::(&directory.new_account, data.as_bytes())?; + let ac = AccountManager { + priv_key, + pub_key, + account_url, + orders_url: acc_rep.orders.unwrap_or_default(), + }; + // TODO: check account data and, if different from config, update them + Ok((ac, nonce)) + } +} diff --git a/acmed/src/acme_proto/certificate.rs b/acmed/src/acme_proto/certificate.rs new file mode 100644 index 0000000..226d181 --- /dev/null +++ b/acmed/src/acme_proto/certificate.rs @@ -0,0 +1,58 @@ +use crate::certificate::{Algorithm, Certificate}; +use crate::error::Error; +use crate::{keygen, storage}; +use openssl::hash::MessageDigest; +use openssl::pkey::{PKey, Private, Public}; +use openssl::stack::Stack; +use openssl::x509::extension::SubjectAlternativeName; +use openssl::x509::X509ReqBuilder; +use serde_json::json; + +fn gen_key_pair(cert: &Certificate) -> Result<(PKey, PKey), Error> { + let (priv_key, pub_key) = match cert.algo { + Algorithm::Rsa2048 => keygen::rsa2048(), + Algorithm::Rsa4096 => keygen::rsa4096(), + Algorithm::EcdsaP256 => keygen::p256(), + Algorithm::EcdsaP384 => keygen::p384(), + }?; + storage::set_priv_key(cert, &priv_key)?; + Ok((priv_key, pub_key)) +} + +fn read_key_pair(cert: &Certificate) -> Result<(PKey, PKey), Error> { + let priv_key = storage::get_priv_key(cert)?; + let pub_key = storage::get_pub_key(cert)?; + Ok((priv_key, pub_key)) +} + +pub fn get_key_pair(cert: &Certificate) -> Result<(PKey, PKey), Error> { + if cert.kp_reuse { + match read_key_pair(cert) { + Ok((priv_key, pub_key)) => Ok((priv_key, pub_key)), + Err(_) => gen_key_pair(cert), + } + } else { + gen_key_pair(cert) + } +} + +pub fn generate_csr( + cert: &Certificate, + priv_key: &PKey, + pub_key: &PKey, +) -> Result { + let domains = cert.domains.join(", DNS:"); + let mut builder = X509ReqBuilder::new()?; + builder.set_pubkey(pub_key)?; + let ctx = builder.x509v3_context(None); + let san = SubjectAlternativeName::new().dns(&domains).build(&ctx)?; + let mut ext_stack = Stack::new()?; + ext_stack.push(san)?; + builder.add_extensions(&ext_stack)?; + builder.sign(priv_key, MessageDigest::sha256())?; + let csr = builder.build(); + let csr = csr.to_der()?; + let csr = super::b64_encode(&csr); + let csr = json!({ "csr": csr }); + Ok(csr.to_string()) +} diff --git a/acmed/src/acme_proto/http.rs b/acmed/src/acme_proto/http.rs new file mode 100644 index 0000000..05acb8a --- /dev/null +++ b/acmed/src/acme_proto/http.rs @@ -0,0 +1,175 @@ +use crate::acme_proto::structs::Directory; +use crate::error::Error; +use http_req::request::{Method, Request}; +use http_req::response::Response; +use http_req::uri::Uri; +use log::{debug, trace}; +use std::str::FromStr; + +const CONTENT_TYPE_JOSE: &str = "application/jose+json"; +const CONTENT_TYPE_JSON: &str = "application/json"; + +fn new_request(uri: &Uri, method: Method) -> Request { + debug!("{}: {}", method, uri); + let useragent = format!( + "{}/{} ({}) {}", + crate::APP_NAME, + crate::APP_VERSION, + env!("ACMED_TARGET"), + env!("ACMED_HTTP_LIB_AGENT") + ); + let mut rb = Request::new(uri); + rb.method(method); + rb.header("User-Agent", &useragent); + // TODO: allow to configure the language + rb.header("Accept-Language", "en-US,en;q=0.5"); + rb +} + +fn send_request(request: &Request) -> Result<(Response, String), Error> { + let mut buffer = Vec::new(); + let res = request.send(&mut buffer)?; + let res_str = String::from_utf8(buffer)?; + if !res.status_code().is_success() { + debug!("Response: {}", res_str); + let msg = format!("HTTP error: {}: {}", res.status_code(), res.reason()); + return Err(msg.into()); + } + Ok((res, res_str)) +} + +fn check_response(_res: &Response) -> Result<(), Error> { + // TODO: implement + Ok(()) +} + +fn get_header(res: &Response, name: &str) -> Result { + match res.headers().get(name) { + Some(v) => Ok(v.to_string()), + None => Err(format!("{}: header not found.", name).into()), + } +} + +fn is_nonce(data: &str) -> bool { + !data.is_empty() + && data + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') +} + +fn nonce_from_response(res: &Response) -> Result { + let nonce = get_header(res, "Replay-Nonce")?; + if is_nonce(&nonce) { + trace!("New nonce: {}", nonce); + Ok(nonce.to_string()) + } else { + let msg = format!("{}: invalid nonce.", nonce); + Err(msg.into()) + } +} + +fn post_jose_type(url: &str, data: &[u8], accept_type: &str) -> Result<(Response, String), Error> { + let uri = url.parse::()?; + let mut request = new_request(&uri, Method::POST); + request.header("Content-Type", CONTENT_TYPE_JOSE); + request.header("Content-Length", &data.len().to_string()); + request.header("Accept", accept_type); + request.body(data); + let rstr = String::from_utf8_lossy(data); + trace!("post_jose: request body: {}", rstr); + let (res, res_body) = send_request(&request)?; + trace!("post_jose: response body: {}", res_body); + check_response(&res)?; + Ok((res, res_body)) +} + +fn post_jose(url: &str, data: &[u8]) -> Result<(Response, String), Error> { + post_jose_type(url, data, CONTENT_TYPE_JSON) +} + +pub fn get_directory(url: &str) -> Result { + let uri = url.parse::()?; + let mut request = new_request(&uri, Method::GET); + request.header("Accept", CONTENT_TYPE_JSON); + let (r, s) = send_request(&request)?; + check_response(&r)?; + Directory::from_str(&s) +} + +pub fn get_nonce(url: &str) -> Result { + let uri = url.parse::()?; + let request = new_request(&uri, Method::HEAD); + let (res, _) = send_request(&request)?; + check_response(&res)?; + nonce_from_response(&res) +} + +pub fn get_obj(url: &str, data: &[u8]) -> Result<(T, String), Error> +where + T: std::str::FromStr, +{ + let (res, res_body) = post_jose(url, data)?; + let obj = T::from_str(&res_body)?; + let nonce = nonce_from_response(&res)?; + Ok((obj, nonce)) +} + +pub fn get_obj_loc(url: &str, data: &[u8]) -> Result<(T, String, String), Error> +where + T: std::str::FromStr, +{ + let (res, res_body) = post_jose(url, data)?; + let obj = T::from_str(&res_body)?; + let location = get_header(&res, "Location")?; + let nonce = nonce_from_response(&res)?; + Ok((obj, location, nonce)) +} + +pub fn post_challenge_response(url: &str, data: &[u8]) -> Result { + let (res, _) = post_jose(url, data)?; + let nonce = nonce_from_response(&res)?; + Ok(nonce) +} + +pub fn get_certificate(url: &str, data: &[u8]) -> Result<(String, String), Error> { + let (res, res_body) = post_jose_type(url, data, CONTENT_TYPE_JSON)?; + let nonce = nonce_from_response(&res)?; + Ok((res_body, nonce)) +} + +#[cfg(test)] +mod tests { + use super::is_nonce; + + #[test] + fn test_nonce_valid() { + let lst = [ + "XFHw3qcgFNZAdw", + "XFHw3qcg-NZAdw", + "XFHw3qcg_NZAdw", + "XFHw3qcg-_ZAdw", + "a", + "1", + "-", + "_", + ]; + for n in lst.iter() { + assert!(is_nonce(n)); + } + } + + #[test] + fn test_nonce_invalid() { + let lst = [ + "", + "rdo9x8gS4K/mZg==", + "rdo9x8gS4K/mZg", + "rdo9x8gS4K+mZg", + "৬", + "京", + ]; + for n in lst.iter() { + assert!(!is_nonce(n)); + } + } +} diff --git a/acmed/src/acme_proto/jws.rs b/acmed/src/acme_proto/jws.rs new file mode 100644 index 0000000..d907eb6 --- /dev/null +++ b/acmed/src/acme_proto/jws.rs @@ -0,0 +1,158 @@ +use crate::acme_proto::b64_encode; +use crate::acme_proto::jws::algorithms::{EdDsaVariant, SignatureAlgorithm}; +use crate::error::Error; +use openssl::ecdsa::EcdsaSig; +use openssl::pkey::{PKey, Private}; +use openssl::sha::sha256; +use serde::Serialize; + +pub mod algorithms; +mod jwk; + +#[derive(Serialize)] +struct JwsData { + protected: String, + payload: String, + signature: String, +} + +#[derive(Serialize)] +struct JwsProtectedHeaderJwk { + alg: String, + jwk: jwk::Jwk, + nonce: String, + url: String, +} + +#[derive(Serialize)] +struct JwsProtectedHeaderKid { + alg: String, + kid: String, + nonce: String, + url: String, +} + +fn es256_sign(data: &[u8], private_key: &PKey) -> Result { + let signature = EcdsaSig::sign(data, private_key.ec_key()?.as_ref())?; + let r = signature.r().to_vec(); + let mut s = signature.s().to_vec(); + let mut signature = r; + signature.append(&mut s); + let signature = b64_encode(&signature); + Ok(signature) +} + +fn eddsa_ed25519_sign(_data: &[u8], _private_key: &PKey) -> Result { + // TODO: implement + Err("EdDSA not implemented.".into()) +} + +fn get_data( + private_key: &PKey, + protected: &str, + payload: &[u8], + sign_alg: SignatureAlgorithm, +) -> Result { + let protected = b64_encode(protected); + let payload = b64_encode(payload); + let signing_input = format!("{}.{}", protected, payload); + let fingerprint = sha256(signing_input.as_bytes()); + let signature = match sign_alg { + SignatureAlgorithm::Es256 => es256_sign(&fingerprint, private_key)?, + SignatureAlgorithm::EdDsa(variant) => match variant { + EdDsaVariant::Ed25519 => eddsa_ed25519_sign(&fingerprint, private_key)?, + }, + }; + let data = JwsData { + protected, + payload, + signature, + }; + let str_data = serde_json::to_string(&data)?; + Ok(str_data) +} + +pub fn encode_jwk( + private_key: &PKey, + payload: &[u8], + url: &str, + nonce: &str, +) -> Result { + let sign_alg = SignatureAlgorithm::from_pkey(private_key)?; + let protected = JwsProtectedHeaderJwk { + alg: sign_alg.to_string(), + jwk: sign_alg.get_jwk(private_key)?, + nonce: nonce.into(), + url: url.into(), + }; + let protected = serde_json::to_string(&protected)?; + get_data(private_key, &protected, payload, sign_alg) +} + +pub fn encode_kid( + private_key: &PKey, + key_id: &str, + payload: &[u8], + url: &str, + nonce: &str, +) -> Result { + let sign_alg = SignatureAlgorithm::from_pkey(private_key)?; + let protected = JwsProtectedHeaderKid { + alg: sign_alg.to_string(), + kid: key_id.to_string(), + nonce: nonce.into(), + url: url.into(), + }; + let protected = serde_json::to_string(&protected)?; + get_data(private_key, &protected, payload, sign_alg) +} + +#[cfg(test)] +mod tests { + use super::{encode_jwk, encode_kid}; + + #[test] + fn test_default_jwk() { + let (priv_key, _) = crate::keygen::p256().unwrap(); + let payload = "Dummy payload 1"; + let payload_b64 = "RHVtbXkgcGF5bG9hZCAx"; + let s = encode_jwk(&priv_key, payload.as_bytes(), "", ""); + assert!(s.is_ok()); + let s = s.unwrap(); + assert!(s.contains("\"protected\"")); + assert!(s.contains("\"payload\"")); + assert!(s.contains("\"signature\"")); + assert!(s.contains(payload_b64)); + } + + #[test] + fn test_default_nopad_jwk() { + let (priv_key, _) = crate::keygen::p256().unwrap(); + let payload = "Dummy payload"; + let payload_b64 = "RHVtbXkgcGF5bG9hZA"; + let payload_b64_pad = "RHVtbXkgcGF5bG9hZA=="; + let s = encode_jwk(&priv_key, payload.as_bytes(), "", ""); + assert!(s.is_ok()); + let s = s.unwrap(); + assert!(s.contains("\"protected\"")); + assert!(s.contains("\"payload\"")); + assert!(s.contains("\"signature\"")); + assert!(s.contains(payload_b64)); + assert!(!s.contains(payload_b64_pad)); + } + + #[test] + fn test_default_kid() { + let (priv_key, _) = crate::keygen::p256().unwrap(); + let payload = "Dummy payload 1"; + let payload_b64 = "RHVtbXkgcGF5bG9hZCAx"; + let key_id = "0x2a"; + let s = encode_kid(&priv_key, key_id, payload.as_bytes(), "", ""); + assert!(s.is_ok()); + let s = s.unwrap(); + assert!(s.contains("\"protected\"")); + assert!(s.contains("\"payload\"")); + assert!(s.contains("\"signature\"")); + assert!(s.contains(payload_b64)); + } +} diff --git a/acmed/src/acme_proto/jws/algorithms.rs b/acmed/src/acme_proto/jws/algorithms.rs new file mode 100644 index 0000000..6c84784 --- /dev/null +++ b/acmed/src/acme_proto/jws/algorithms.rs @@ -0,0 +1,181 @@ +use super::jwk::{EdDsaEd25519Jwk, Es256Jwk, Jwk}; +use crate::acme_proto::b64_encode; +use crate::error::Error; +use crate::keygen; +use openssl::bn::{BigNum, BigNumContext}; +use openssl::ec::EcGroup; +use openssl::nid::Nid; +use openssl::pkey::{Id, PKey, Private, Public}; +use serde_json::json; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, PartialEq, Eq)] +pub enum EdDsaVariant { + Ed25519, +} + +impl fmt::Display for EdDsaVariant { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = match self { + EdDsaVariant::Ed25519 => "Ed25519", + }; + write!(f, "{}", s) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum SignatureAlgorithm { + Es256, + EdDsa(EdDsaVariant), +} + +impl fmt::Display for SignatureAlgorithm { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = match self { + SignatureAlgorithm::Es256 => "ES256", + SignatureAlgorithm::EdDsa(_) => "EdDSA", + }; + write!(f, "{}", s) + } +} + +impl FromStr for SignatureAlgorithm { + type Err = Error; + + fn from_str(data: &str) -> Result { + match data.to_lowercase().as_str() { + "es256" => Ok(SignatureAlgorithm::Es256), + "eddsa-ed25519" => Ok(SignatureAlgorithm::EdDsa(EdDsaVariant::Ed25519)), + _ => Err(format!("{}: unknown signature algorithm", data).into()), + } + } +} + +impl SignatureAlgorithm { + pub fn from_pkey(private_key: &PKey) -> Result { + match private_key.id() { + Id::EC => match private_key.ec_key()?.group().curve_name() { + Some(nid) => { + if nid == Nid::X9_62_PRIME256V1 { + Ok(SignatureAlgorithm::Es256) + // TODO: add support for Ed25519 keys + } else { + Err(format!("{}: unsupported EC key type", nid.as_raw()).into()) + } + } + None => Err("EC curve: name not found".into()), + }, + _ => Err(format!("{}: unsupported key id", private_key.id().as_raw()).into()), + } + } + + fn get_p256_coordinates(private_key: &PKey) -> Result<(String, String), Error> { + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); + let mut ctx = BigNumContext::new().unwrap(); + let mut x = BigNum::new().unwrap(); + let mut y = BigNum::new().unwrap(); + private_key + .ec_key() + .unwrap() + .public_key() + .affine_coordinates_gfp(&group, &mut x, &mut y, &mut ctx)?; + let x = b64_encode(&x.to_vec()); + let y = b64_encode(&y.to_vec()); + Ok((x, y)) + } + + pub fn get_jwk_thumbprint(&self, private_key: &PKey) -> Result { + let jwk = match self { + SignatureAlgorithm::Es256 => { + let (x, y) = SignatureAlgorithm::get_p256_coordinates(private_key)?; + json!({ + "crv": "P-256", + "kty": "EC", + "x": x, + "y": y, + }) + } + SignatureAlgorithm::EdDsa(_crv) => json!({ + // TODO: implement EdDsa + }), + }; + Ok(jwk.to_string()) + } + + pub fn get_jwk(&self, private_key: &PKey) -> Result { + let jwk = match self { + SignatureAlgorithm::Es256 => { + let (x, y) = SignatureAlgorithm::get_p256_coordinates(private_key)?; + Jwk::Es256(Es256Jwk::new(&x, &y)) + } + // TODO: implement EdDsa + SignatureAlgorithm::EdDsa(_crv) => Jwk::EdDsaEd25519(EdDsaEd25519Jwk::new()), + }; + Ok(jwk) + } + + pub fn gen_key_pair(&self) -> Result<(PKey, PKey), Error> { + match self { + SignatureAlgorithm::Es256 => keygen::p256(), + SignatureAlgorithm::EdDsa(EdDsaVariant::Ed25519) => Err("Not implemented".into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::{EdDsaVariant, SignatureAlgorithm}; + use openssl::ec::EcKey; + use openssl::pkey::PKey; + use std::str::FromStr; + + #[test] + fn test_es256_from_str() { + let variants = ["ES256", "Es256", "es256"]; + for v in variants.iter() { + let a = SignatureAlgorithm::from_str(v); + assert!(a.is_ok()); + let a = a.unwrap(); + assert_eq!(a, SignatureAlgorithm::Es256); + } + } + + #[test] + fn test_es256_to_str() { + let a = SignatureAlgorithm::Es256; + assert_eq!(a.to_string().as_str(), "ES256"); + } + + #[test] + fn test_eddsa_ed25519_from_str() { + let variants = ["ES256", "Es256", "es256"]; + for v in variants.iter() { + let a = SignatureAlgorithm::from_str(v); + assert!(a.is_ok()); + let a = a.unwrap(); + assert_eq!(a, SignatureAlgorithm::Es256); + } + } + + #[test] + fn test_eddsa_ed25519_to_str() { + let a = SignatureAlgorithm::EdDsa(EdDsaVariant::Ed25519); + assert_eq!(a.to_string().as_str(), "EdDSA"); + } + + #[test] + fn test_from_p256() { + let pem = b"-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg6To1BW8qTehGhPca +0eMcW8iQU4yA02dvtKkuqfny4HChRANCAAQwxx+j3wYGzD5LSFNBTLlT7J+7rWrq +4BGdR8705iwpBeOQgMpLj+9vuFutlVtmoYpJSYa9+49Hxz8aCe1AQeWt +-----END PRIVATE KEY-----"; + let ek = EcKey::private_key_from_pem(pem).unwrap(); + let k = PKey::from_ec_key(ek).unwrap(); + let s = SignatureAlgorithm::from_pkey(&k); + assert!(s.is_ok()); + let s = s.unwrap(); + assert_eq!(s, SignatureAlgorithm::Es256) + } +} diff --git a/acmed/src/acme_proto/jws/jwk.rs b/acmed/src/acme_proto/jws/jwk.rs new file mode 100644 index 0000000..a451ab4 --- /dev/null +++ b/acmed/src/acme_proto/jws/jwk.rs @@ -0,0 +1,43 @@ +use serde::Serialize; + +#[derive(Serialize)] +#[serde(untagged)] +pub enum Jwk { + Es256(Es256Jwk), + EdDsaEd25519(EdDsaEd25519Jwk), +} + +#[derive(Serialize)] +pub struct Es256Jwk { + kty: String, + #[serde(rename = "use")] + jwk_use: String, + crv: String, + alg: String, + x: String, + y: String, +} + +impl Es256Jwk { + pub fn new(x: &str, y: &str) -> Self { + Es256Jwk { + kty: "EC".into(), + jwk_use: "sig".into(), + crv: "P-256".into(), + alg: "ES256".into(), + x: x.to_string(), + y: y.to_string(), + } + } +} + +#[derive(Serialize)] +pub struct EdDsaEd25519Jwk { + // TODO: implement +} + +impl EdDsaEd25519Jwk { + pub fn new() -> Self { + EdDsaEd25519Jwk {} + } +} diff --git a/acmed/src/acme_proto/structs.rs b/acmed/src/acme_proto/structs.rs new file mode 100644 index 0000000..4f132dc --- /dev/null +++ b/acmed/src/acme_proto/structs.rs @@ -0,0 +1,23 @@ +#[macro_export] +macro_rules! deserialize_from_str { + ($t: ty) => { + impl FromStr for $t { + type Err = Error; + + fn from_str(data: &str) -> Result { + let res = serde_json::from_str(data)?; + Ok(res) + } + } + }; +} + +mod account; +mod authorization; +mod directory; +mod order; + +pub use account::{Account, AccountDeactivation, AccountResponse, AccountUpdate}; +pub use authorization::{Authorization, AuthorizationStatus, Challenge}; +pub use directory::Directory; +pub use order::{Identifier, IdentifierType, NewOrder, Order, OrderStatus}; diff --git a/acmed/src/acme_proto/structs/account.rs b/acmed/src/acme_proto/structs/account.rs new file mode 100644 index 0000000..264a3c4 --- /dev/null +++ b/acmed/src/acme_proto/structs/account.rs @@ -0,0 +1,154 @@ +use crate::error::Error; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Account { + pub contact: Vec, + pub terms_of_service_agreed: bool, + pub only_return_existing: bool, +} + +impl Account { + pub fn new(contact: &[String]) -> Self { + Account { + contact: contact.iter().map(|v| format!("mailto:{}", v)).collect(), + terms_of_service_agreed: true, + only_return_existing: false, + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountResponse { + pub status: String, + pub contact: Option>, + pub terms_of_service_agreed: Option, + pub external_account_binding: Option, + pub orders: Option, +} + +deserialize_from_str!(AccountResponse); + +// TODO: implement account update +#[allow(dead_code)] +#[derive(Serialize)] +pub struct AccountUpdate { + pub contact: Vec, +} + +impl AccountUpdate { + #[allow(dead_code)] + pub fn new(contact: &[String]) -> Self { + AccountUpdate { + contact: contact.into(), + } + } +} + +// TODO: implement account deactivation +#[allow(dead_code)] +#[derive(Serialize)] +pub struct AccountDeactivation { + pub status: String, +} + +impl AccountDeactivation { + #[allow(dead_code)] + pub fn new() -> Self { + AccountDeactivation { + status: "deactivated".into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_account_new() { + let emails = vec![ + "derp@example.com".to_string(), + "derp.derpson@example.com".to_string(), + ]; + let a = Account::new(&emails); + assert_eq!(a.contact.len(), 2); + assert_eq!(a.terms_of_service_agreed, true); + assert_eq!(a.only_return_existing, false); + let a_str = serde_json::to_string(&a); + assert!(a_str.is_ok()); + let a_str = a_str.unwrap(); + assert!(a_str.starts_with("{")); + assert!(a_str.ends_with("}")); + assert!(a_str.contains("\"contact\"")); + assert!(a_str.contains("\"mailto:derp@example.com\"")); + assert!(a_str.contains("\"mailto:derp.derpson@example.com\"")); + assert!(a_str.contains("\"termsOfServiceAgreed\"")); + assert!(a_str.contains("\"onlyReturnExisting\"")); + assert!(a_str.contains("true")); + assert!(a_str.contains("false")); + } + + #[test] + fn test_account_response() { + let data = "{ + \"status\": \"valid\", + \"contact\": [ + \"mailto:cert-admin@example.org\", + \"mailto:admin@example.org\" + ], + \"termsOfServiceAgreed\": true, + \"orders\": \"https://example.com/acme/orders/rzGoeA\" +}"; + let account_resp = AccountResponse::from_str(data); + assert!(account_resp.is_ok()); + let account_resp = account_resp.unwrap(); + assert_eq!(account_resp.status, "valid"); + assert!(account_resp.contact.is_some()); + let contacts = account_resp.contact.unwrap(); + assert_eq!(contacts.len(), 2); + assert_eq!(contacts[0], "mailto:cert-admin@example.org"); + assert_eq!(contacts[1], "mailto:admin@example.org"); + assert!(account_resp.external_account_binding.is_none()); + assert!(account_resp.terms_of_service_agreed.is_some()); + assert!(account_resp.terms_of_service_agreed.unwrap()); + assert_eq!( + account_resp.orders, + Some("https://example.com/acme/orders/rzGoeA".into()) + ); + } + + #[test] + fn test_account_update() { + let emails = vec![ + "mailto:derp@example.com".to_string(), + "mailto:derp.derpson@example.com".to_string(), + ]; + let au = AccountUpdate::new(&emails); + assert_eq!(au.contact.len(), 2); + let au_str = serde_json::to_string(&au); + assert!(au_str.is_ok()); + let au_str = au_str.unwrap(); + assert!(au_str.starts_with("{")); + assert!(au_str.ends_with("}")); + assert!(au_str.contains("\"contact\"")); + assert!(au_str.contains("\"mailto:derp@example.com\"")); + assert!(au_str.contains("\"mailto:derp.derpson@example.com\"")); + } + + #[test] + fn test_account_deactivation() { + let ad = AccountDeactivation::new(); + assert_eq!(ad.status, "deactivated"); + let ad_str = serde_json::to_string(&ad); + assert!(ad_str.is_ok()); + let ad_str = ad_str.unwrap(); + assert!(ad_str.starts_with("{")); + assert!(ad_str.ends_with("}")); + assert!(ad_str.contains("\"status\"")); + assert!(ad_str.contains("\"deactivated\"")); + } +} diff --git a/acmed/src/acme_proto/structs/authorization.rs b/acmed/src/acme_proto/structs/authorization.rs new file mode 100644 index 0000000..7d47ed6 --- /dev/null +++ b/acmed/src/acme_proto/structs/authorization.rs @@ -0,0 +1,299 @@ +use crate::acme_proto::b64_encode; +use crate::acme_proto::jws::algorithms::SignatureAlgorithm; +use crate::acme_proto::structs::Identifier; +use crate::error::Error; +use openssl::pkey::{PKey, Private}; +use openssl::sha::sha256; +use serde::Deserialize; +use std::fmt; +use std::str::FromStr; + +#[derive(Deserialize)] +pub struct Authorization { + pub identifier: Identifier, + pub status: AuthorizationStatus, + pub expires: Option, + pub challenges: Vec, + pub wildcard: Option, +} + +impl FromStr for Authorization { + type Err = Error; + + fn from_str(data: &str) -> Result { + let mut res: Self = serde_json::from_str(data)?; + res.challenges.retain(|c| *c != Challenge::Unknown); + Ok(res) + } +} + +#[derive(Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuthorizationStatus { + Pending, + Valid, + Invalid, + Deactivated, + Expired, + Revoked, +} + +impl fmt::Display for AuthorizationStatus { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = match self { + AuthorizationStatus::Pending => "pending", + AuthorizationStatus::Valid => "valid", + AuthorizationStatus::Invalid => "invalid", + AuthorizationStatus::Deactivated => "deactivated", + AuthorizationStatus::Expired => "expired", + AuthorizationStatus::Revoked => "revoked", + }; + write!(f, "{}", s) + } +} + +#[derive(PartialEq, Deserialize)] +#[serde(tag = "type")] +pub enum Challenge { + #[serde(rename = "http-01")] + Http01(TokenChallenge), + #[serde(rename = "dns-01")] + Dns01(TokenChallenge), + // TODO: tls-alpn-01 + #[serde(other)] + Unknown, +} + +deserialize_from_str!(Challenge); + +impl Challenge { + pub fn get_url(&self) -> String { + match self { + Challenge::Http01(tc) | Challenge::Dns01(tc) => tc.url.to_owned(), + Challenge::Unknown => String::new(), + } + } + + pub fn get_proof(&self, private_key: &PKey) -> Result { + match self { + Challenge::Http01(tc) => tc.key_authorization(private_key), + Challenge::Dns01(tc) => { + let ka = tc.key_authorization(private_key)?; + let a = sha256(ka.as_bytes()); + let a = b64_encode(&a); + Ok(a) + } + Challenge::Unknown => Ok(String::new()), + } + } + + pub fn get_file_name(&self) -> String { + match self { + Challenge::Http01(tc) => tc.token.to_owned(), + Challenge::Dns01(_) => String::new(), + Challenge::Unknown => String::new(), + } + } +} + +#[derive(PartialEq, Deserialize)] +pub struct TokenChallenge { + pub url: String, + pub status: Option, + pub validated: Option, + pub error: Option, // TODO: set the correct object + pub token: String, +} + +impl TokenChallenge { + fn key_authorization(&self, private_key: &PKey) -> Result { + let sa = SignatureAlgorithm::from_pkey(private_key)?; + let thumbprint = sa.get_jwk_thumbprint(private_key)?; + let thumbprint = sha256(thumbprint.as_bytes()); + let thumbprint = b64_encode(&thumbprint); + let auth = format!("{}.{}", self.token, thumbprint); + Ok(auth) + } +} + +#[derive(Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ChallengeStatus { + Pending, + Processing, + Valid, + Invalid, +} + +#[cfg(test)] +mod tests { + use super::{Authorization, AuthorizationStatus, Challenge, ChallengeStatus}; + use crate::acme_proto::structs::IdentifierType; + use std::str::FromStr; + + #[test] + fn test_authorization() { + let data = "{ + \"status\": \"pending\", + \"identifier\": { + \"type\": \"dns\", + \"value\": \"example.com\" + }, + \"challenges\": [] +}"; + let a = Authorization::from_str(data); + assert!(a.is_ok()); + let a = a.unwrap(); + assert_eq!(a.status, AuthorizationStatus::Pending); + assert!(a.challenges.is_empty()); + let i = a.identifier; + assert_eq!(i.id_type, IdentifierType::Dns); + assert_eq!(i.value, "example.com".to_string()); + } + + #[test] + fn test_authorization_challenge() { + let data = "{ + \"status\": \"pending\", + \"identifier\": { + \"type\": \"dns\", + \"value\": \"example.com\" + }, + \"challenges\": [ + { + \"type\": \"dns-01\", + \"status\": \"pending\", + \"url\": \"https://example.com/chall/jYWxob3N0OjE\", + \"token\": \"1y9UVMUvkqQVljCsnwlRLsbJcwN9nx-qDd6JHzXQQsw\" + } + ] +}"; + let a = Authorization::from_str(data); + assert!(a.is_ok()); + let a = a.unwrap(); + assert_eq!(a.status, AuthorizationStatus::Pending); + assert_eq!(a.challenges.len(), 1); + let i = a.identifier; + assert_eq!(i.id_type, IdentifierType::Dns); + assert_eq!(i.value, "example.com".to_string()); + } + + #[test] + fn test_authorization_unknown_challenge() { + let data = "{ + \"status\": \"pending\", + \"identifier\": { + \"type\": \"dns\", + \"value\": \"example.com\" + }, + \"challenges\": [ + { + \"type\": \"invalid-challenge-01\", + \"status\": \"pending\", + \"url\": \"https://example.com/chall/jYWxob3N0OjE\", + \"token\": \"1y9UVMUvkqQVljCsnwlRLsbJcwN9nx-qDd6JHzXQQsw\" + } + ] +}"; + let a = Authorization::from_str(data); + assert!(a.is_ok()); + let a = a.unwrap(); + assert_eq!(a.status, AuthorizationStatus::Pending); + assert!(a.challenges.is_empty()); + let i = a.identifier; + assert_eq!(i.id_type, IdentifierType::Dns); + assert_eq!(i.value, "example.com".to_string()); + } + + #[test] + fn test_invalid_authorization() { + let data = "{ + \"status\": \"pending\", + \"identifier\": { + \"type\": \"foo\", + \"value\": \"bar\" + }, + \"challenges\": [] +}"; + let a = Authorization::from_str(data); + assert!(a.is_err()); + } + + #[test] + fn test_http01_challenge() { + let data = "{ + \"type\": \"http-01\", + \"url\": \"https://example.com/acme/chall/prV_B7yEyA4\", + \"status\": \"pending\", + \"token\": \"LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0\" +}"; + let challenge = Challenge::from_str(data); + assert!(challenge.is_ok()); + let challenge = challenge.unwrap(); + let c = match challenge { + Challenge::Http01(c) => c, + _ => { + assert!(false); + return; + } + }; + assert_eq!( + c.url, + "https://example.com/acme/chall/prV_B7yEyA4".to_string() + ); + assert_eq!(c.status, Some(ChallengeStatus::Pending)); + assert_eq!( + c.token, + "LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0".to_string() + ); + assert!(c.validated.is_none()); + assert!(c.error.is_none()); + } + + #[test] + fn test_dns01_challenge() { + let data = "{ + \"type\": \"http-01\", + \"url\": \"https://example.com/acme/chall/prV_B7yEyA4\", + \"status\": \"valid\", + \"token\": \"LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0\" +}"; + let challenge = Challenge::from_str(data); + assert!(challenge.is_ok()); + let challenge = challenge.unwrap(); + let c = match challenge { + Challenge::Http01(c) => c, + _ => { + assert!(false); + return; + } + }; + assert_eq!( + c.url, + "https://example.com/acme/chall/prV_B7yEyA4".to_string() + ); + assert_eq!(c.status, Some(ChallengeStatus::Valid)); + assert_eq!( + c.token, + "LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0".to_string() + ); + assert!(c.validated.is_none()); + assert!(c.error.is_none()); + } + + #[test] + fn test_unknown_challenge_type() { + let data = "{ + \"type\": \"invalid-01\", + \"url\": \"https://example.com/acme/chall/prV_B7yEyA4\", + \"status\": \"pending\", + \"token\": \"LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0\" +}"; + let challenge = Challenge::from_str(data); + assert!(challenge.is_ok()); + match challenge.unwrap() { + Challenge::Unknown => assert!(true), + _ => assert!(false), + } + } +} diff --git a/acmed/src/acme_proto/structs/directory.rs b/acmed/src/acme_proto/structs/directory.rs new file mode 100644 index 0000000..6751f07 --- /dev/null +++ b/acmed/src/acme_proto/structs/directory.rs @@ -0,0 +1,147 @@ +use crate::error::Error; +use serde::Deserialize; +use std::str::FromStr; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryMeta { + pub terms_of_service: Option, + pub website: Option, + pub caa_identities: Option>, + pub external_account_required: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Directory { + pub meta: Option, + pub new_nonce: String, + pub new_account: String, + pub new_order: String, + pub new_authz: Option, + pub revoke_cert: String, + pub key_change: String, +} + +deserialize_from_str!(Directory); + +#[cfg(test)] +mod tests { + use super::Directory; + use std::str::FromStr; + + #[test] + fn test_directory() { + let data = "{ + \"newAccount\": \"https://example.org/acme/new-acct\", + \"newNonce\": \"https://example.org/acme/new-nonce\", + \"newOrder\": \"https://example.org/acme/new-order\", + \"revokeCert\": \"https://example.org/acme/revoke-cert\", + \"newAuthz\": \"https://example.org/acme/new-authz\", + \"keyChange\": \"https://example.org/acme/key-change\" +}"; + let parsed_dir = Directory::from_str(data); + assert!(parsed_dir.is_ok()); + let parsed_dir = parsed_dir.unwrap(); + assert_eq!(parsed_dir.new_nonce, "https://example.org/acme/new-nonce"); + assert_eq!(parsed_dir.new_account, "https://example.org/acme/new-acct"); + assert_eq!(parsed_dir.new_order, "https://example.org/acme/new-order"); + assert_eq!( + parsed_dir.new_authz, + Some("https://example.org/acme/new-authz".to_string()) + ); + assert_eq!( + parsed_dir.revoke_cert, + "https://example.org/acme/revoke-cert" + ); + assert_eq!(parsed_dir.key_change, "https://example.org/acme/key-change"); + assert!(parsed_dir.meta.is_none()); + } + + #[test] + fn test_directory_no_authz() { + let data = "{ + \"newAccount\": \"https://example.org/acme/new-acct\", + \"newNonce\": \"https://example.org/acme/new-nonce\", + \"newOrder\": \"https://example.org/acme/new-order\", + \"revokeCert\": \"https://example.org/acme/revoke-cert\", + \"keyChange\": \"https://example.org/acme/key-change\" +}"; + let parsed_dir = Directory::from_str(data); + assert!(parsed_dir.is_ok()); + let parsed_dir = parsed_dir.unwrap(); + assert_eq!(parsed_dir.new_nonce, "https://example.org/acme/new-nonce"); + assert_eq!(parsed_dir.new_account, "https://example.org/acme/new-acct"); + assert_eq!(parsed_dir.new_order, "https://example.org/acme/new-order"); + assert!(parsed_dir.new_authz.is_none()); + assert_eq!( + parsed_dir.revoke_cert, + "https://example.org/acme/revoke-cert" + ); + assert_eq!(parsed_dir.key_change, "https://example.org/acme/key-change"); + assert!(parsed_dir.meta.is_none()); + } + + #[test] + fn test_directory_meta() { + let data = "{ + \"keyChange\": \"https://example.org/acme/key-change\", + \"meta\": { + \"caaIdentities\": [ + \"example.org\" + ], + \"termsOfService\": \"https://example.org/documents/tos.pdf\", + \"website\": \"https://example.org/\" + }, + \"newAccount\": \"https://example.org/acme/new-acct\", + \"newNonce\": \"https://example.org/acme/new-nonce\", + \"newOrder\": \"https://example.org/acme/new-order\", + \"revokeCert\": \"https://example.org/acme/revoke-cert\" +}"; + let parsed_dir = Directory::from_str(&data); + assert!(parsed_dir.is_ok()); + let parsed_dir = parsed_dir.unwrap(); + assert!(parsed_dir.meta.is_some()); + let meta = parsed_dir.meta.unwrap(); + assert_eq!( + meta.terms_of_service, + Some("https://example.org/documents/tos.pdf".to_string()) + ); + assert_eq!(meta.website, Some("https://example.org/".to_string())); + assert!(meta.caa_identities.is_some()); + let caa_identities = meta.caa_identities.unwrap(); + assert_eq!(caa_identities.len(), 1); + assert_eq!(caa_identities.first(), Some(&"example.org".to_string())); + assert!(meta.external_account_required.is_none()); + } + + #[test] + fn test_directory_extra_fields() { + let data = "{ + \"foo\": \"bar\", + \"keyChange\": \"https://example.org/acme/key-change\", + \"newAccount\": \"https://example.org/acme/new-acct\", + \"baz\": \"quz\", + \"newNonce\": \"https://example.org/acme/new-nonce\", + \"newAuthz\": \"https://example.org/acme/new-authz\", + \"newOrder\": \"https://example.org/acme/new-order\", + \"revokeCert\": \"https://example.org/acme/revoke-cert\" +}"; + let parsed_dir = Directory::from_str(&data); + assert!(parsed_dir.is_ok()); + let parsed_dir = parsed_dir.unwrap(); + assert_eq!(parsed_dir.new_nonce, "https://example.org/acme/new-nonce"); + assert_eq!(parsed_dir.new_account, "https://example.org/acme/new-acct"); + assert_eq!(parsed_dir.new_order, "https://example.org/acme/new-order"); + assert_eq!( + parsed_dir.new_authz, + Some("https://example.org/acme/new-authz".to_string()) + ); + assert_eq!( + parsed_dir.revoke_cert, + "https://example.org/acme/revoke-cert" + ); + assert_eq!(parsed_dir.key_change, "https://example.org/acme/key-change"); + assert!(parsed_dir.meta.is_none()); + } +} diff --git a/acmed/src/acme_proto/structs/order.rs b/acmed/src/acme_proto/structs/order.rs new file mode 100644 index 0000000..ee7c3a9 --- /dev/null +++ b/acmed/src/acme_proto/structs/order.rs @@ -0,0 +1,135 @@ +use crate::error::Error; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +#[derive(Serialize)] +pub struct NewOrder { + pub identifiers: Vec, + pub not_before: Option, + pub not_after: Option, +} + +impl NewOrder { + pub fn new(domains: &[String]) -> Self { + NewOrder { + identifiers: domains.iter().map(|n| Identifier::new_dns(n)).collect(), + not_before: None, + not_after: None, + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Order { + pub status: OrderStatus, + pub expires: Option, + pub identifiers: Vec, + pub not_before: Option, + pub not_after: Option, + pub error: Option, // TODO: set the correct structure + pub authorizations: Vec, + pub finalize: String, + pub certificate: Option, +} + +deserialize_from_str!(Order); + +#[derive(Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OrderStatus { + Pending, + Ready, + Processing, + Valid, + Invalid, +} + +impl fmt::Display for OrderStatus { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = match self { + OrderStatus::Pending => "pending", + OrderStatus::Ready => "ready", + OrderStatus::Processing => "processing", + OrderStatus::Valid => "valid", + OrderStatus::Invalid => "invalid", + }; + write!(f, "{}", s) + } +} + +#[derive(Deserialize, Serialize)] +pub struct Identifier { + #[serde(rename = "type")] + pub id_type: IdentifierType, + pub value: String, +} + +impl Identifier { + pub fn new_dns(value: &str) -> Self { + Identifier { + id_type: IdentifierType::Dns, + value: value.to_string(), + } + } +} + +deserialize_from_str!(Identifier); + +impl fmt::Display for Identifier { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}:{}", self.id_type, self.value) + } +} + +#[derive(Debug, Deserialize, Serialize, Eq, PartialEq)] +pub enum IdentifierType { + #[serde(rename = "dns")] + Dns, +} + +impl fmt::Display for IdentifierType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = match self { + IdentifierType::Dns => "dns", + }; + write!(f, "{}", s) + } +} + +#[cfg(test)] +mod tests { + use super::{Identifier, IdentifierType}; + use std::str::FromStr; + + #[test] + fn id_serialize() { + let reference = "{\"type\":\"dns\",\"value\":\"test.example.org\"}"; + let id = Identifier { + id_type: IdentifierType::Dns, + value: "test.example.org".to_string(), + }; + let id_json = serde_json::to_string(&id); + assert!(id_json.is_ok()); + let id_json = id_json.unwrap(); + assert_eq!(id_json, reference.to_string()); + } + + #[test] + fn id_deserialize_valid() { + let id_str = "{\"type\":\"dns\",\"value\":\"test.example.org\"}"; + let id = Identifier::from_str(id_str); + assert!(id.is_ok()); + let id = id.unwrap(); + assert_eq!(id.id_type, IdentifierType::Dns); + assert_eq!(id.value, "test.example.org".to_string()); + } + + #[test] + fn id_deserialize_invalid_type() { + let id_str = "{\"type\":\"trololo\",\"value\":\"test.example.org\"}"; + let id = Identifier::from_str(id_str); + assert!(id.is_err()); + } +} diff --git a/acmed/src/acmed.rs b/acmed/src/acmed.rs deleted file mode 100644 index 09eb6fa..0000000 --- a/acmed/src/acmed.rs +++ /dev/null @@ -1,333 +0,0 @@ -use crate::config::{self, Hook}; -use crate::errors::Error; -use crate::hooks; -use crate::storage::Storage; -use acme_lib::{Directory, DirectoryUrl}; -use log::{debug, info, warn}; -use openssl; -use serde::Serialize; -use std::time::Duration; -use std::{fmt, thread}; -use x509_parser::parse_x509_der; - -#[derive(Clone, Debug, PartialEq, PartialOrd, Eq, Ord)] -pub enum Format { - Der, - Pem, -} - -impl fmt::Display for Format { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let s = match self { - Format::Der => "der", - Format::Pem => "pem", - }; - write!(f, "{}", s) - } -} - -#[derive(Clone, Debug)] -pub enum Challenge { - Http01, - Dns01, -} - -impl Challenge { - pub fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "http-01" => Ok(Challenge::Http01), - "dns-01" => Ok(Challenge::Dns01), - _ => Err(Error::new(&format!("{}: unknown challenge.", s))), - } - } -} - -impl fmt::Display for Challenge { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let s = match self { - Challenge::Http01 => "http-01", - Challenge::Dns01 => "dns-01", - }; - write!(f, "{}", s) - } -} - -#[derive(Clone, Debug)] -pub enum Algorithm { - Rsa2048, - Rsa4096, - EcdsaP256, - EcdsaP384, -} - -impl Algorithm { - pub fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "rsa2048" => Ok(Algorithm::Rsa2048), - "rsa4096" => Ok(Algorithm::Rsa4096), - "ecdsa_p256" => Ok(Algorithm::EcdsaP256), - "ecdsa_p384" => Ok(Algorithm::EcdsaP384), - _ => Err(Error::new(&format!("{}: unknown algorithm.", s))), - } - } -} - -impl fmt::Display for Algorithm { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let s = match self { - Algorithm::Rsa2048 => "rsa2048", - Algorithm::Rsa4096 => "rsa4096", - Algorithm::EcdsaP256 => "ecdsa-p256", - Algorithm::EcdsaP384 => "ecdsa-p384", - }; - write!(f, "{}", s) - } -} - -#[derive(Serialize)] -struct HookData { - // Common - domains: Vec, - algorithm: String, - challenge: String, - status: String, - // Challenge hooks - current_domain: String, - token: String, - proof: String, -} - -#[derive(Debug)] -struct Certificate { - domains: Vec, - algo: Algorithm, - kp_reuse: bool, - storage: Storage, - email: String, - remote_url: String, - challenge: Challenge, - challenge_hooks: Vec, - post_operation_hooks: Vec, -} - -impl Certificate { - fn should_renew(&self) -> bool { - let domain = self.domains.first().unwrap(); - let raw_cert = match self.storage.get_certificate(&Format::Der) { - Ok(c) => match c { - Some(d) => d, - None => { - debug!( - "{} certificate for {} is empty or does not exists", - self.algo, domain - ); - return true; - } - }, - Err(e) => { - warn!("{}", e); - return true; - } - }; - match parse_x509_der(&raw_cert) { - Ok((_, cert)) => { - // TODO: allow a custom duration (using time-parse ?) - let renewal_time = - cert.tbs_certificate.validity.not_after - time::Duration::weeks(3); - debug!( - "{} certificate for {}: not after: {}", - self.algo, - domain, - cert.tbs_certificate.validity.not_after.asctime() - ); - debug!( - "{} certificate for {}: renew on: {}", - self.algo, - domain, - renewal_time.asctime() - ); - time::now_utc() > renewal_time - } - Err(_) => true, - } - } - - fn call_challenge_hooks(&self, token: &str, proof: &str, domain: &str) -> Result<(), Error> { - let hook_data = HookData { - domains: self.domains.to_owned(), - algorithm: self.algo.to_string(), - challenge: self.challenge.to_string(), - status: format!("Validation pending for {}", domain), - current_domain: domain.to_string(), - token: token.to_string(), - proof: proof.to_string(), - }; - hooks::call_multiple(&hook_data, &self.challenge_hooks)?; - Ok(()) - } - - fn call_post_operation_hooks(&self, status: &str) -> Result<(), Error> { - let hook_data = HookData { - domains: self.domains.to_owned(), - algorithm: self.algo.to_string(), - challenge: self.challenge.to_string(), - status: status.to_string(), - current_domain: "".to_string(), - token: "".to_string(), - proof: "".to_string(), - }; - hooks::call_multiple(&hook_data, &self.post_operation_hooks)?; - Ok(()) - } - - fn renew(&mut self) -> Result<(), Error> { - // TODO: do it in a separated thread since it may take a while - let (name, alt_names_str) = self.domains.split_first().unwrap(); - let mut alt_names = vec![]; - for n in alt_names_str.iter() { - alt_names.push(n.as_str()); - } - info!("Renewing the {} certificate for {}", self.algo, name); - let url = DirectoryUrl::Other(&self.remote_url); - let dir = Directory::from_url(self.storage.to_owned(), url)?; - let acc = dir.account(&self.email)?; - let mut ord_new = acc.new_order(name, &alt_names)?; - let ord_csr = loop { - if let Some(ord_csr) = ord_new.confirm_validations() { - break ord_csr; - } - let auths = ord_new.authorizations()?; - for auth in auths.iter() { - match self.challenge { - Challenge::Http01 => { - let chall = auth.http_challenge(); - let token = chall.http_token(); - let proof = chall.http_proof(); - self.call_challenge_hooks(&token, &proof, auth.domain_name())?; - chall.validate(crate::DEFAULT_POOL_TIME)?; - } - Challenge::Dns01 => { - let chall = auth.dns_challenge(); - let proof = chall.dns_proof(); - self.call_challenge_hooks("", &proof, auth.domain_name())?; - chall.validate(crate::DEFAULT_POOL_TIME)?; - } - }; - } - ord_new.refresh()?; - }; - - let mut raw_crt = vec![]; - let mut raw_pk = vec![]; - if self.kp_reuse { - raw_crt = self - .storage - .get_certificate(&Format::Der)? - .unwrap_or_else(|| vec![]); - raw_pk = self - .storage - .get_private_key(&Format::Der)? - .unwrap_or_else(|| vec![]); - }; - let (pkey_pri, pkey_pub) = if !raw_crt.is_empty() && !raw_pk.is_empty() { - ( - openssl::pkey::PKey::private_key_from_der(&raw_pk)?, - openssl::x509::X509::from_der(&raw_crt)?.public_key()?, - ) - } else { - match self.algo { - Algorithm::Rsa2048 => acme_lib::create_rsa_key(2048), - Algorithm::Rsa4096 => acme_lib::create_rsa_key(4096), - Algorithm::EcdsaP256 => acme_lib::create_p256_key(), - Algorithm::EcdsaP384 => acme_lib::create_p384_key(), - } - }; - let ord_cert = ord_csr.finalize_pkey(pkey_pri, pkey_pub, crate::DEFAULT_POOL_TIME)?; - ord_cert.download_and_save_cert()?; - Ok(()) - } -} - -pub struct Acmed { - certs: Vec, -} - -impl Acmed { - pub fn new(config_file: &str) -> Result { - let cnf = config::from_file(config_file)?; - - let mut certs = Vec::new(); - for crt in cnf.certificate.iter() { - let cert = Certificate { - domains: crt.domains.to_owned(), - algo: crt.get_algorithm()?, - kp_reuse: crt.get_kp_reuse(), - storage: Storage { - account_directory: cnf.get_account_dir(), - account_name: crt.email.to_owned(), - crt_directory: crt.get_crt_dir(&cnf), - crt_name: crt.get_crt_name(), - crt_name_format: crt.get_crt_name_format(), - formats: crt.get_formats()?, - algo: crt.get_algorithm()?, - cert_file_mode: cnf.get_cert_file_mode(), - cert_file_owner: cnf.get_cert_file_user(), - cert_file_group: cnf.get_cert_file_group(), - pk_file_mode: cnf.get_pk_file_mode(), - pk_file_owner: cnf.get_pk_file_user(), - pk_file_group: cnf.get_pk_file_group(), - file_pre_create_hooks: crt.get_file_pre_create_hooks(&cnf)?, - file_post_create_hooks: crt.get_file_post_create_hooks(&cnf)?, - file_pre_edit_hooks: crt.get_file_pre_edit_hooks(&cnf)?, - file_post_edit_hooks: crt.get_file_post_edit_hooks(&cnf)?, - }, - email: crt.email.to_owned(), - remote_url: crt.get_remote_url(&cnf)?, - challenge: crt.get_challenge()?, - challenge_hooks: crt.get_challenge_hooks(&cnf)?, - post_operation_hooks: crt.get_post_operation_hooks(&cnf)?, - }; - certs.push(cert); - } - - Ok(Acmed { certs }) - } - - pub fn run(&mut self) { - loop { - for crt in self.certs.iter_mut() { - debug!("{:?}", crt); - if crt.should_renew() { - // TODO: keep track of (not yet implemented) threads and wait for them to end. - let status = match crt.renew() { - Ok(_) => "Success.".to_string(), - Err(e) => { - let msg = format!( - "Unable to renew the {} certificate for {}: {}", - crt.algo, - crt.domains.first().unwrap(), - e - ); - warn!("{}", msg); - format!("Failed: {}", msg) - } - }; - match crt.call_post_operation_hooks(&status) { - Ok(_) => {} - Err(e) => { - let msg = format!( - "{} certificate for {}: post-operation hook error: {}", - crt.algo, - crt.domains.first().unwrap(), - e - ); - warn!("{}", msg); - } - }; - } - } - - thread::sleep(Duration::from_secs(crate::DEFAULT_SLEEP_TIME)); - } - } -} diff --git a/acmed/src/certificate.rs b/acmed/src/certificate.rs new file mode 100644 index 0000000..b134103 --- /dev/null +++ b/acmed/src/certificate.rs @@ -0,0 +1,159 @@ +use crate::acme_proto::Challenge; +use crate::error::Error; +use crate::hooks::{self, ChallengeHookData, Hook, PostOperationHookData}; +use crate::storage::{certificate_files_exists, get_certificate}; +use log::debug; +use std::fmt; +use time::{strptime, Duration}; + +#[derive(Clone, Debug)] +pub enum Algorithm { + Rsa2048, + Rsa4096, + EcdsaP256, + EcdsaP384, +} + +impl Algorithm { + pub fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "rsa2048" => Ok(Algorithm::Rsa2048), + "rsa4096" => Ok(Algorithm::Rsa4096), + "ecdsa_p256" => Ok(Algorithm::EcdsaP256), + "ecdsa_p384" => Ok(Algorithm::EcdsaP384), + _ => Err(format!("{}: unknown algorithm.", s).into()), + } + } +} + +impl fmt::Display for Algorithm { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = match self { + Algorithm::Rsa2048 => "rsa2048", + Algorithm::Rsa4096 => "rsa4096", + Algorithm::EcdsaP256 => "ecdsa-p256", + Algorithm::EcdsaP384 => "ecdsa-p384", + }; + write!(f, "{}", s) + } +} + +#[derive(Debug)] +pub struct Certificate { + pub domains: Vec, + pub algo: Algorithm, + pub kp_reuse: bool, + pub email: String, + pub remote_url: String, + pub challenge: Challenge, + pub challenge_hooks: Vec, + pub post_operation_hooks: Vec, + pub account_directory: String, + pub crt_directory: String, + pub crt_name: String, + pub crt_name_format: String, + pub cert_file_mode: u32, + pub cert_file_owner: Option, + pub cert_file_group: Option, + pub pk_file_mode: u32, + pub pk_file_owner: Option, + pub pk_file_group: Option, + pub file_pre_create_hooks: Vec, + pub file_post_create_hooks: Vec, + pub file_pre_edit_hooks: Vec, + pub file_post_edit_hooks: Vec, +} + +impl fmt::Display for Certificate { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let challenge_hooks = self + .challenge_hooks + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(", "); + let post_operation_hooks = self + .post_operation_hooks + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(", "); + write!( + f, + "Certificate information: +Domains: {domains} +Algorithm: {algo} +Contact: {email} +Private key reuse: {kp_reuse} +Challenge: {challenge} +Challenge hooks: {challenge_hooks} +Post operation hooks: {post_operation_hooks}", + domains = self.domains.join(", "), + algo = self.algo, + email = self.email, + kp_reuse = self.kp_reuse, + challenge = self.challenge, + challenge_hooks = challenge_hooks, + post_operation_hooks = post_operation_hooks, + ) + } +} + +impl Certificate { + pub fn should_renew(&self) -> Result { + if !certificate_files_exists(&self) { + debug!("certificate does not exist: requesting one"); + return Ok(true); + } + let cert = get_certificate(&self)?; + let not_after = cert.not_after().to_string(); + // TODO: check the time format and put it in a const + let not_after = match strptime(¬_after, "%b %d %T %Y") { + Ok(t) => t, + Err(_) => { + let msg = format!("invalid time string: {}", not_after); + return Err(msg.into()); + } + }; + debug!("not after: {}", not_after.asctime()); + // TODO: allow a custom duration (using time-parse ?) + let renewal_time = not_after - Duration::weeks(3); + debug!("renew on: {}", renewal_time.asctime()); + let renew = time::now_utc() > renewal_time; + if renew { + debug!("The certificate will be renewed now."); + } else { + debug!("The certificate will not be renewed now."); + } + Ok(renew) + } + + pub fn call_challenge_hooks( + &self, + file_name: &str, + proof: &str, + domain: &str, + ) -> Result<(), Error> { + let hook_data = ChallengeHookData { + domains: self.domains.to_owned(), + algorithm: self.algo.to_string(), + challenge: self.challenge.to_string(), + current_domain: domain.to_string(), + file_name: file_name.to_string(), + proof: proof.to_string(), + }; + hooks::call_multiple(&hook_data, &self.challenge_hooks)?; + Ok(()) + } + + pub fn call_post_operation_hooks(&self, status: &str) -> Result<(), Error> { + let hook_data = PostOperationHookData { + domains: self.domains.to_owned(), + algorithm: self.algo.to_string(), + challenge: self.challenge.to_string(), + status: status.to_string(), + }; + hooks::call_multiple(&hook_data, &self.post_operation_hooks)?; + Ok(()) + } +} diff --git a/acmed/src/config.rs b/acmed/src/config.rs index c137dab..778cc08 100644 --- a/acmed/src/config.rs +++ b/acmed/src/config.rs @@ -1,5 +1,7 @@ -use crate::acmed::{Algorithm, Challenge, Format}; -use crate::errors::Error; +use crate::acme_proto::Challenge; +use crate::certificate::Algorithm; +use crate::error::Error; +use crate::hooks; use log::info; use serde::Deserialize; use std::fs::{self, File}; @@ -27,10 +29,18 @@ impl Config { account_dir.to_string() } - pub fn get_hook(&self, name: &str) -> Result, Error> { + pub fn get_hook(&self, name: &str) -> Result, Error> { for hook in self.hook.iter() { if name == hook.name { - return Ok(vec![hook.clone()]); + let h = hooks::Hook { + name: hook.name.to_owned(), + cmd: hook.cmd.to_owned(), + args: hook.args.to_owned(), + stdin: hook.stdin.to_owned(), + stdout: hook.stdout.to_owned(), + stderr: hook.stderr.to_owned(), + }; + return Ok(vec![h]); } } for grp in self.group.iter() { @@ -43,7 +53,7 @@ impl Config { return Ok(ret); } } - Err(Error::new(&format!("{}: hook not found", name))) + Err(format!("{}: hook not found", name).into()) } pub fn get_cert_file_mode(&self) -> u32 { @@ -113,7 +123,7 @@ pub struct Endpoint { pub url: String, } -#[derive(Deserialize, Clone, Debug)] +#[derive(Deserialize)] pub struct Hook { pub name: String, pub cmd: String, @@ -169,26 +179,6 @@ impl Certificate { } } - pub fn get_formats(&self) -> Result, Error> { - let ret = match &self.formats { - Some(fmts) => { - let mut lst = Vec::new(); - for f in fmts.iter() { - lst.push(match f.as_str() { - "der" => Format::Der, - "pem" => Format::Pem, - _ => return Err(Error::new(&format!("{}: unknown format.", f))), - }); - } - lst.sort(); - lst.dedup(); - lst - } - None => vec![crate::DEFAULT_FMT], - }; - Ok(ret) - } - pub fn get_crt_name(&self) -> String { match &self.name { Some(n) => n.to_string(), @@ -223,42 +213,42 @@ impl Certificate { return Ok(endpoint.url.to_owned()); } } - Err(Error::new(&format!("{}: unknown endpoint.", self.endpoint))) + Err(format!("{}: unknown endpoint.", self.endpoint).into()) } - pub fn get_challenge_hooks(&self, cnf: &Config) -> Result, Error> { + pub fn get_challenge_hooks(&self, cnf: &Config) -> Result, Error> { get_hooks(&self.challenge_hooks, cnf) } - pub fn get_post_operation_hooks(&self, cnf: &Config) -> Result, Error> { + pub fn get_post_operation_hooks(&self, cnf: &Config) -> Result, Error> { match &self.post_operation_hooks { Some(hooks) => get_hooks(hooks, cnf), None => Ok(vec![]), } } - pub fn get_file_pre_create_hooks(&self, cnf: &Config) -> Result, Error> { + pub fn get_file_pre_create_hooks(&self, cnf: &Config) -> Result, Error> { match &self.file_pre_create_hooks { Some(hooks) => get_hooks(hooks, cnf), None => Ok(vec![]), } } - pub fn get_file_post_create_hooks(&self, cnf: &Config) -> Result, Error> { + pub fn get_file_post_create_hooks(&self, cnf: &Config) -> Result, Error> { match &self.file_post_create_hooks { Some(hooks) => get_hooks(hooks, cnf), None => Ok(vec![]), } } - pub fn get_file_pre_edit_hooks(&self, cnf: &Config) -> Result, Error> { + pub fn get_file_pre_edit_hooks(&self, cnf: &Config) -> Result, Error> { match &self.file_pre_edit_hooks { Some(hooks) => get_hooks(hooks, cnf), None => Ok(vec![]), } } - pub fn get_file_post_edit_hooks(&self, cnf: &Config) -> Result, Error> { + pub fn get_file_post_edit_hooks(&self, cnf: &Config) -> Result, Error> { match &self.file_post_edit_hooks { Some(hooks) => get_hooks(hooks, cnf), None => Ok(vec![]), @@ -266,7 +256,7 @@ impl Certificate { } } -fn get_hooks(lst: &[String], cnf: &Config) -> Result, Error> { +fn get_hooks(lst: &[String], cnf: &Config) -> Result, Error> { let mut res = vec![]; for name in lst.iter() { let mut h = cnf.get_hook(&name)?; diff --git a/acmed/src/encoding.rs b/acmed/src/encoding.rs deleted file mode 100644 index 612bd47..0000000 --- a/acmed/src/encoding.rs +++ /dev/null @@ -1,203 +0,0 @@ -use crate::acmed::Format; -use acme_lib::persist::PersistKind; -use acme_lib::Error; -use log::debug; -use pem::{encode, Pem}; - -enum ConversionType { - PemToDer, - DerToPem, - None, -} - -impl ConversionType { - fn get(from: &Format, to: &Format) -> Self { - match from { - Format::Pem => match to { - Format::Pem => ConversionType::None, - Format::Der => ConversionType::PemToDer, - }, - Format::Der => match to { - Format::Pem => ConversionType::DerToPem, - Format::Der => ConversionType::None, - }, - } - } -} - -fn pem_to_der(data: &[u8]) -> Result, Error> { - // We need to convert all CRLF into LF since x509_parser only supports the later. - let mut data = data.to_vec(); - data.retain(|&c| c != 0x0d); - match x509_parser::pem::pem_to_der(&data) { - Ok((_, cert)) => Ok(cert.contents), - Err(_) => Err(Error::Other("invalid PEM certificate".to_string())), - } -} - -fn der_to_pem(data: &[u8], kind: PersistKind) -> Result, Error> { - // TODO: allow the user to specify if we should use CRLF or LF (default is CRLF). - let tag_str = match kind { - PersistKind::AccountPrivateKey => "PRIVATE KEY", - PersistKind::PrivateKey => "PRIVATE KEY", - PersistKind::Certificate => "CERTIFICATE", - }; - let pem = Pem { - tag: String::from(tag_str), - contents: data.to_vec(), - }; - let res = encode(&pem); - Ok(res.into_bytes()) -} - -/// Convert a certificate encoded in a format into another format. -/// -/// Warning: if the data contains multiple certificates (eg: a PEM -/// certificate chain), converting to DER will only include the first -/// certificate, the others will be lost. -pub fn convert( - data: &[u8], - from: &Format, - to: &Format, - kind: PersistKind, -) -> Result, Error> { - debug!("Converting a certificate from {} to {}", from, to); - match ConversionType::get(from, to) { - ConversionType::PemToDer => pem_to_der(data), - ConversionType::DerToPem => der_to_pem(data, kind), - ConversionType::None => Ok(data.to_vec()), - } -} - -#[cfg(test)] -mod tests { - use super::convert; - use crate::acmed::Format; - use acme_lib::persist::PersistKind; - - // Test data generated using: - // - // openssl req -x509 -nodes -newkey ED25519 -keyout key.pem -out cert.pem -days 365 - // openssl pkey -inform PEM -outform DER -in key.pem -out key.der - // openssl x509 -inform PEM -outform DER -in cert.pem -out cert.der - pub const PK_PEM: &'static [u8] = b"-----BEGIN PRIVATE KEY-----\r -MC4CAQAwBQYDK2VwBCIEIJRKGvS3yKtxf+zjzvDTHx2dIcDXz0LKeBLnqE0H8ALb\r ------END PRIVATE KEY-----\r\n"; - pub const PK_DER: &'static [u8] = b"\x30\x2E\x02\x01\x00\x30\x05\x06\x03\ -\x2B\x65\x70\x04\x22\x04\x20\x94\x4A\x1A\xF4\xB7\xC8\xAB\x71\x7F\xEC\xE3\xCE\ -\xF0\xD3\x1F\x1D\x9D\x21\xC0\xD7\xCF\x42\xCA\x78\x12\xE7\xA8\x4D\x07\xF0\x02\ -\xDB"; - pub const CERT_PEM: &'static [u8] = b"-----BEGIN CERTIFICATE-----\r -MIICLzCCAeGgAwIBAgIUdlMenq7MVkx5b1lFrvaBwvjlIEQwBQYDK2VwMIGMMQsw\r -CQYDVQQGEwJGUjEZMBcGA1UECAwQw4PCjmxlLWRlLUZyYW5jZTEOMAwGA1UEBwwF\r -UGFyaXMxDjAMBgNVBAoMBUFDTUVkMRkwFwYDVQQDDBB0ZXN0LmV4YW1wbGUub3Jn\r -MScwJQYJKoZIhvcNAQkBFhhpbnZhbGlkQHRlc3QuZXhhbXBsZS5vcmcwHhcNMTkw\r -MzE0MTE0NDI1WhcNMjAwMzEzMTE0NDI1WjCBjDELMAkGA1UEBhMCRlIxGTAXBgNV\r -BAgMEMODwo5sZS1kZS1GcmFuY2UxDjAMBgNVBAcMBVBhcmlzMQ4wDAYDVQQKDAVB\r -Q01FZDEZMBcGA1UEAwwQdGVzdC5leGFtcGxlLm9yZzEnMCUGCSqGSIb3DQEJARYY\r -aW52YWxpZEB0ZXN0LmV4YW1wbGUub3JnMCowBQYDK2VwAyEAboP+S9yfoP3euk+C\r -FgMIZ9J/Q6KxLwteCAvJSkbWTwKjUzBRMB0GA1UdDgQWBBT49UVSayhFWUaRiyiB\r -oXkSoRgynTAfBgNVHSMEGDAWgBT49UVSayhFWUaRiyiBoXkSoRgynTAPBgNVHRMB\r -Af8EBTADAQH/MAUGAytlcANBAPITjbIYNioMcpMDMvbyzHf2IqPFiNW/Ce3KTS8T\r -zseNNFkN0oOc55UAd2ECe6gGOXB0r4MycFOM9ccR2t8ttwE=\r ------END CERTIFICATE-----\r\n"; - pub const CERT_DER: &'static [u8] = b"\x30\x82\x02\x2F\x30\x82\x01\xE1\xA0\ -\x03\x02\x01\x02\x02\x14\x76\x53\x1E\x9E\xAE\xCC\x56\x4C\x79\x6F\x59\x45\xAE\ -\xF6\x81\xC2\xF8\xE5\x20\x44\x30\x05\x06\x03\x2B\x65\x70\x30\x81\x8C\x31\x0B\ -\x30\x09\x06\x03\x55\x04\x06\x13\x02\x46\x52\x31\x19\x30\x17\x06\x03\x55\x04\ -\x08\x0C\x10\xC3\x83\xC2\x8E\x6C\x65\x2D\x64\x65\x2D\x46\x72\x61\x6E\x63\x65\ -\x31\x0E\x30\x0C\x06\x03\x55\x04\x07\x0C\x05\x50\x61\x72\x69\x73\x31\x0E\x30\ -\x0C\x06\x03\x55\x04\x0A\x0C\x05\x41\x43\x4D\x45\x64\x31\x19\x30\x17\x06\x03\ -\x55\x04\x03\x0C\x10\x74\x65\x73\x74\x2E\x65\x78\x61\x6D\x70\x6C\x65\x2E\x6F\ -\x72\x67\x31\x27\x30\x25\x06\x09\x2A\x86\x48\x86\xF7\x0D\x01\x09\x01\x16\x18\ -\x69\x6E\x76\x61\x6C\x69\x64\x40\x74\x65\x73\x74\x2E\x65\x78\x61\x6D\x70\x6C\ -\x65\x2E\x6F\x72\x67\x30\x1E\x17\x0D\x31\x39\x30\x33\x31\x34\x31\x31\x34\x34\ -\x32\x35\x5A\x17\x0D\x32\x30\x30\x33\x31\x33\x31\x31\x34\x34\x32\x35\x5A\x30\ -\x81\x8C\x31\x0B\x30\x09\x06\x03\x55\x04\x06\x13\x02\x46\x52\x31\x19\x30\x17\ -\x06\x03\x55\x04\x08\x0C\x10\xC3\x83\xC2\x8E\x6C\x65\x2D\x64\x65\x2D\x46\x72\ -\x61\x6E\x63\x65\x31\x0E\x30\x0C\x06\x03\x55\x04\x07\x0C\x05\x50\x61\x72\x69\ -\x73\x31\x0E\x30\x0C\x06\x03\x55\x04\x0A\x0C\x05\x41\x43\x4D\x45\x64\x31\x19\ -\x30\x17\x06\x03\x55\x04\x03\x0C\x10\x74\x65\x73\x74\x2E\x65\x78\x61\x6D\x70\ -\x6C\x65\x2E\x6F\x72\x67\x31\x27\x30\x25\x06\x09\x2A\x86\x48\x86\xF7\x0D\x01\ -\x09\x01\x16\x18\x69\x6E\x76\x61\x6C\x69\x64\x40\x74\x65\x73\x74\x2E\x65\x78\ -\x61\x6D\x70\x6C\x65\x2E\x6F\x72\x67\x30\x2A\x30\x05\x06\x03\x2B\x65\x70\x03\ -\x21\x00\x6E\x83\xFE\x4B\xDC\x9F\xA0\xFD\xDE\xBA\x4F\x82\x16\x03\x08\x67\xD2\ -\x7F\x43\xA2\xB1\x2F\x0B\x5E\x08\x0B\xC9\x4A\x46\xD6\x4F\x02\xA3\x53\x30\x51\ -\x30\x1D\x06\x03\x55\x1D\x0E\x04\x16\x04\x14\xF8\xF5\x45\x52\x6B\x28\x45\x59\ -\x46\x91\x8B\x28\x81\xA1\x79\x12\xA1\x18\x32\x9D\x30\x1F\x06\x03\x55\x1D\x23\ -\x04\x18\x30\x16\x80\x14\xF8\xF5\x45\x52\x6B\x28\x45\x59\x46\x91\x8B\x28\x81\ -\xA1\x79\x12\xA1\x18\x32\x9D\x30\x0F\x06\x03\x55\x1D\x13\x01\x01\xFF\x04\x05\ -\x30\x03\x01\x01\xFF\x30\x05\x06\x03\x2B\x65\x70\x03\x41\x00\xF2\x13\x8D\xB2\ -\x18\x36\x2A\x0C\x72\x93\x03\x32\xF6\xF2\xCC\x77\xF6\x22\xA3\xC5\x88\xD5\xBF\ -\x09\xED\xCA\x4D\x2F\x13\xCE\xC7\x8D\x34\x59\x0D\xD2\x83\x9C\xE7\x95\x00\x77\ -\x61\x02\x7B\xA8\x06\x39\x70\x74\xAF\x83\x32\x70\x53\x8C\xF5\xC7\x11\xDA\xDF\ -\x2D\xB7\x01"; - - #[test] - fn test_der_to_der() { - let res = convert( - &CERT_DER, - &Format::Der, - &Format::Der, - PersistKind::Certificate, - ); - assert!(res.is_ok()); - let res = res.unwrap(); - assert_eq!(CERT_DER, res.as_slice()); - } - - #[test] - fn test_pem_to_pem() { - let res = convert( - &CERT_PEM, - &Format::Pem, - &Format::Pem, - PersistKind::Certificate, - ); - assert!(res.is_ok()); - let res = res.unwrap(); - assert_eq!(CERT_PEM, res.as_slice()); - } - - #[test] - fn test_der_to_pem_pk() { - let res = convert(&PK_DER, &Format::Der, &Format::Pem, PersistKind::PrivateKey); - assert!(res.is_ok()); - let res = res.unwrap(); - assert_eq!(PK_PEM, res.as_slice()); - } - - #[test] - fn test_der_to_pem_crt() { - let res = convert( - &CERT_DER, - &Format::Der, - &Format::Pem, - PersistKind::Certificate, - ); - assert!(res.is_ok()); - let res = res.unwrap(); - assert_eq!(CERT_PEM, res.as_slice()); - } - - #[test] - fn test_pem_to_der_crt() { - let res = convert( - &CERT_PEM, - &Format::Pem, - &Format::Der, - PersistKind::Certificate, - ); - assert!(res.is_ok()); - let res = res.unwrap(); - assert_eq!(CERT_DER, res.as_slice()); - } - - #[test] - fn test_pem_to_der_pk() { - let res = convert(&PK_PEM, &Format::Pem, &Format::Der, PersistKind::PrivateKey); - assert!(res.is_ok()); - let res = res.unwrap(); - assert_eq!(PK_DER, res.as_slice()); - } -} diff --git a/acmed/src/error.rs b/acmed/src/error.rs new file mode 100644 index 0000000..3d8c355 --- /dev/null +++ b/acmed/src/error.rs @@ -0,0 +1,87 @@ +use std::fmt; + +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl From<&str> for Error { + fn from(error: &str) -> Self { + Error { + message: error.to_string(), + } + } +} + +impl From for Error { + fn from(error: String) -> Self { + error.as_str().into() + } +} + +impl From<&String> for Error { + fn from(error: &String) -> Self { + error.as_str().into() + } +} + +impl From for Error { + fn from(error: std::io::Error) -> Self { + format!("IO error: {}", error).into() + } +} + +impl From for Error { + fn from(error: std::string::FromUtf8Error) -> Self { + format!("UTF-8 error: {}", error).into() + } +} + +impl From for Error { + fn from(error: syslog::Error) -> Self { + format!("syslog error: {}", error).into() + } +} + +impl From for Error { + fn from(error: toml::de::Error) -> Self { + format!("IO error: {}", error).into() + } +} + +impl From for Error { + fn from(error: serde_json::error::Error) -> Self { + format!("IO error: {}", error).into() + } +} + +impl From for Error { + fn from(error: handlebars::TemplateRenderError) -> Self { + format!("Template error: {}", error).into() + } +} + +impl From for Error { + fn from(error: openssl::error::ErrorStack) -> Self { + format!("{}", error).into() + } +} + +impl From for Error { + fn from(error: http_req::error::Error) -> Self { + format!("HTTP error: {}", error).into() + } +} + +#[cfg(unix)] +impl From for Error { + fn from(error: nix::Error) -> Self { + format!("{}", error).into() + } +} diff --git a/acmed/src/errors.rs b/acmed/src/errors.rs deleted file mode 100644 index 350b3ce..0000000 --- a/acmed/src/errors.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::fmt; - -#[derive(Debug)] -pub struct Error { - pub message: String, -} - -impl Error { - pub fn new(msg: &str) -> Self { - Error { - message: msg.to_string(), - } - } -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.message) - } -} - -impl From for Error { - fn from(error: std::io::Error) -> Self { - Error::new(&format!("IO error: {}", error)) - } -} - -impl From<&str> for Error { - fn from(error: &str) -> Self { - Error::new(error) - } -} - -impl From for Error { - fn from(error: syslog::Error) -> Self { - Error::new(&format!("syslog error: {}", error)) - } -} - -impl From for Error { - fn from(error: toml::de::Error) -> Self { - Error::new(&format!("IO error: {}", error)) - } -} - -impl From for Error { - fn from(error: acme_lib::Error) -> Self { - let msg = match error { - acme_lib::Error::ApiProblem(e) => format!("An API call failed: {}", e), - acme_lib::Error::Call(e) => format!("An API call failed: {}", e), - acme_lib::Error::Base64Decode(e) => format!("base 64 decode error: {}", e), - acme_lib::Error::Json(e) => format!("JSON error: {}", e), - acme_lib::Error::Io(e) => format!("IO error: {}", e), - acme_lib::Error::Other(s) => s, - }; - Error::new(&msg) - } -} - -impl From for Error { - fn from(error: handlebars::TemplateRenderError) -> Self { - Error::new(&format!("Template error: {}", error)) - } -} - -impl From for Error { - fn from(error: openssl::error::ErrorStack) -> Self { - Error::new(&format!("{}", error)) - } -} - -#[cfg(unix)] -impl From for Error { - fn from(error: nix::Error) -> Self { - Error::new(&format!("{}", error)) - } -} diff --git a/acmed/src/hooks.rs b/acmed/src/hooks.rs index 4efcc26..6fac147 100644 --- a/acmed/src/hooks.rs +++ b/acmed/src/hooks.rs @@ -1,12 +1,55 @@ -use crate::config::Hook; -use crate::errors::Error; +use crate::error::Error; use handlebars::Handlebars; use log::debug; use serde::Serialize; +use std::fmt; use std::fs::File; use std::io::prelude::*; +use std::path::PathBuf; use std::process::{Command, Stdio}; +#[derive(Serialize)] +pub struct PostOperationHookData { + pub domains: Vec, + pub algorithm: String, + pub challenge: String, + pub status: String, +} + +#[derive(Serialize)] +pub struct ChallengeHookData { + pub domains: Vec, + pub algorithm: String, + pub challenge: String, + pub current_domain: String, + pub file_name: String, + pub proof: String, +} + +#[derive(Serialize)] +pub struct FileStorageHookData { + // TODO: add the current operation (create/edit) + pub file_name: String, + pub file_directory: String, + pub file_path: PathBuf, +} + +#[derive(Clone, Debug)] +pub struct Hook { + pub name: String, + pub cmd: String, + pub args: Option>, + pub stdin: Option, + pub stdout: Option, + pub stderr: Option, +} + +impl fmt::Display for Hook { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.name) + } +} + macro_rules! get_hook_output { ($out: expr, $reg: ident, $data: expr) => {{ match $out { @@ -20,13 +63,6 @@ macro_rules! get_hook_output { }}; } -pub fn call_multiple(data: &T, hooks: &[Hook]) -> Result<(), Error> { - for hook in hooks.iter() { - call(data, &hook)?; - } - Ok(()) -} - pub fn call(data: &T, hook: &Hook) -> Result<(), Error> { debug!("Calling hook: {}", hook.name); let reg = Handlebars::new(); @@ -66,3 +102,10 @@ pub fn call(data: &T, hook: &Hook) -> Result<(), Error> { }; Ok(()) } + +pub fn call_multiple(data: &T, hooks: &[Hook]) -> Result<(), Error> { + for hook in hooks.iter() { + call(data, &hook)?; + } + Ok(()) +} diff --git a/acmed/src/keygen.rs b/acmed/src/keygen.rs new file mode 100644 index 0000000..58b389a --- /dev/null +++ b/acmed/src/keygen.rs @@ -0,0 +1,45 @@ +use crate::error::Error; +use openssl::ec::{EcGroup, EcKey}; +use openssl::nid::Nid; +use openssl::pkey::{PKey, Private, Public}; +use openssl::rsa::Rsa; + +fn gen_ec_pair(nid: Nid) -> Result<(PKey, PKey), Error> { + let group = EcGroup::from_curve_name(nid).unwrap(); + let ec_priv_key = EcKey::generate(&group).unwrap(); + let public_key_point = ec_priv_key.public_key(); + let ec_pub_key = EcKey::from_public_key(&group, public_key_point).unwrap(); + Ok(( + PKey::from_ec_key(ec_priv_key).unwrap(), + PKey::from_ec_key(ec_pub_key).unwrap(), + )) +} + +pub fn p256() -> Result<(PKey, PKey), Error> { + gen_ec_pair(Nid::X9_62_PRIME256V1) +} + +pub fn p384() -> Result<(PKey, PKey), Error> { + gen_ec_pair(Nid::SECP384R1) +} + +fn gen_rsa_pair(nb_bits: u32) -> Result<(PKey, PKey), Error> { + let priv_key = Rsa::generate(nb_bits).unwrap(); + let pub_key = Rsa::from_public_components( + priv_key.n().to_owned().unwrap(), + priv_key.e().to_owned().unwrap(), + ) + .unwrap(); + Ok(( + PKey::from_rsa(priv_key).unwrap(), + PKey::from_rsa(pub_key).unwrap(), + )) +} + +pub fn rsa2048() -> Result<(PKey, PKey), Error> { + gen_rsa_pair(2048) +} + +pub fn rsa4096() -> Result<(PKey, PKey), Error> { + gen_rsa_pair(4096) +} diff --git a/acmed/src/logs.rs b/acmed/src/logs.rs index 7e8d9b6..ef7210f 100644 --- a/acmed/src/logs.rs +++ b/acmed/src/logs.rs @@ -1,4 +1,4 @@ -use crate::errors::Error; +use crate::error::Error; use env_logger::Builder; use log::LevelFilter; use syslog::Facility; @@ -18,7 +18,7 @@ fn get_loglevel(log_level: Option<&str>) -> Result { "debug" => LevelFilter::Debug, "trace" => LevelFilter::Trace, _ => { - return Err(Error::new(&format!("{}: invalid log level", v))); + return Err(format!("{}: invalid log level", v).into()); } }, None => crate::DEFAULT_LOG_LEVEL, @@ -27,7 +27,11 @@ fn get_loglevel(log_level: Option<&str>) -> Result { } fn set_log_syslog(log_level: LevelFilter) -> Result<(), Error> { - syslog::init(Facility::LOG_DAEMON, log_level, Some(crate::APP_NAME))?; + syslog::init( + Facility::LOG_DAEMON, + log_level, + Some(env!("CARGO_PKG_NAME")), + )?; Ok(()) } diff --git a/acmed/src/main.rs b/acmed/src/main.rs index ae47cf1..bf9751b 100644 --- a/acmed/src/main.rs +++ b/acmed/src/main.rs @@ -1,34 +1,41 @@ +use crate::main_event_loop::MainEventLoop; use clap::{App, Arg}; use daemonize::Daemonize; use log::{error, LevelFilter}; -mod acmed; +mod acme_proto; +mod certificate; mod config; -mod encoding; -mod errors; +mod error; mod hooks; +mod keygen; mod logs; +mod main_event_loop; mod storage; -pub const APP_NAME: &str = "acmed"; +pub const APP_NAME: &str = "ACMEd"; +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const DEFAULT_PID_FILE: &str = "/var/run/admed.pid"; pub const DEFAULT_CONFIG_FILE: &str = "/etc/acmed/acmed.toml"; pub const DEFAULT_ACCOUNTS_DIR: &str = "/etc/acmed/accounts"; pub const DEFAULT_CERT_DIR: &str = "/etc/acmed/certs"; -pub const DEFAULT_CERT_FORMAT: &str = "{name}_{algo}.{kind}.{ext}"; +pub const DEFAULT_CERT_FORMAT: &str = "{{name}}_{{algo}}.{{file_type}}.{{ext}}"; pub const DEFAULT_ALGO: &str = "rsa2048"; -pub const DEFAULT_FMT: acmed::Format = acmed::Format::Pem; pub const DEFAULT_SLEEP_TIME: u64 = 3600; pub const DEFAULT_POOL_TIME: u64 = 5000; pub const DEFAULT_CERT_FILE_MODE: u32 = 0o644; pub const DEFAULT_PK_FILE_MODE: u32 = 0o600; +pub const DEFAULT_ACCOUNT_FILE_MODE: u32 = 0o600; pub const DEFAULT_KP_REUSE: bool = false; pub const DEFAULT_LOG_SYSTEM: logs::LogSystem = logs::LogSystem::SysLog; pub const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Warn; +pub const DEFAULT_JWS_SIGN_ALGO: &str = "ES256"; +pub const DEFAULT_POOL_NB_TRIES: usize = 10; +pub const DEFAULT_POOL_WAIT_SEC: u64 = 5; fn main() { - let matches = App::new("acmed") - .version("0.2.1") + let matches = App::new(APP_NAME) + .version(APP_VERSION) .arg( Arg::with_name("config") .short("c") @@ -98,7 +105,7 @@ fn main() { } let config_file = matches.value_of("config").unwrap_or(DEFAULT_CONFIG_FILE); - let mut srv = match acmed::Acmed::new(&config_file) { + let mut srv = match MainEventLoop::new(&config_file) { Ok(s) => s, Err(e) => { error!("{}", e); diff --git a/acmed/src/main_event_loop.rs b/acmed/src/main_event_loop.rs new file mode 100644 index 0000000..f1c8989 --- /dev/null +++ b/acmed/src/main_event_loop.rs @@ -0,0 +1,92 @@ +use crate::acme_proto::request_certificate; +use crate::certificate::Certificate; +use crate::config; +use crate::error::Error; +use log::{debug, warn}; +use std::thread; +use std::time::Duration; + +pub struct MainEventLoop { + certs: Vec, +} + +impl MainEventLoop { + pub fn new(config_file: &str) -> Result { + let cnf = config::from_file(config_file)?; + + let mut certs = Vec::new(); + for crt in cnf.certificate.iter() { + let cert = Certificate { + domains: crt.domains.to_owned(), + algo: crt.get_algorithm()?, + kp_reuse: crt.get_kp_reuse(), + email: crt.email.to_owned(), + remote_url: crt.get_remote_url(&cnf)?, + challenge: crt.get_challenge()?, + challenge_hooks: crt.get_challenge_hooks(&cnf)?, + post_operation_hooks: crt.get_post_operation_hooks(&cnf)?, + account_directory: cnf.get_account_dir(), + crt_directory: crt.get_crt_dir(&cnf), + crt_name: crt.get_crt_name(), + crt_name_format: crt.get_crt_name_format(), + cert_file_mode: cnf.get_cert_file_mode(), + cert_file_owner: cnf.get_cert_file_user(), + cert_file_group: cnf.get_cert_file_group(), + pk_file_mode: cnf.get_pk_file_mode(), + pk_file_owner: cnf.get_pk_file_user(), + pk_file_group: cnf.get_pk_file_group(), + file_pre_create_hooks: crt.get_file_pre_create_hooks(&cnf)?, + file_post_create_hooks: crt.get_file_post_create_hooks(&cnf)?, + file_pre_edit_hooks: crt.get_file_pre_edit_hooks(&cnf)?, + file_post_edit_hooks: crt.get_file_post_edit_hooks(&cnf)?, + }; + certs.push(cert); + } + + Ok(MainEventLoop { certs }) + } + + pub fn run(&mut self) { + loop { + for crt in self.certs.iter_mut() { + debug!("{}", crt); + match crt.should_renew() { + Ok(sr) => { + if sr { + let status = match request_certificate(crt) { + Ok(_) => "Success.".to_string(), + Err(e) => { + let msg = format!( + "Unable to renew the {} certificate for {}: {}", + crt.algo, + crt.domains.first().unwrap(), + e + ); + warn!("{}", msg); + format!("Failed: {}", msg) + } + }; + match crt.call_post_operation_hooks(&status) { + Ok(_) => {} + Err(e) => { + let msg = format!( + "{} certificate for {}: post-operation hook error: {}", + crt.algo, + crt.domains.first().unwrap(), + e + ); + warn!("{}", msg); + } + }; + } + } + Err(e) => { + warn!("{}", e); + } + }; + } + + thread::sleep(Duration::from_secs(crate::DEFAULT_SLEEP_TIME)); + } + } +} diff --git a/acmed/src/storage.rs b/acmed/src/storage.rs index 5abbae9..970e91b 100644 --- a/acmed/src/storage.rs +++ b/acmed/src/storage.rs @@ -1,221 +1,250 @@ -use crate::acmed::{Algorithm, Format}; -use crate::config::Hook; -use crate::encoding::convert; -use crate::errors; -use crate::hooks; -use acme_lib::persist::{Persist, PersistKey, PersistKind}; -use acme_lib::Error; -use log::debug; -use serde::Serialize; +use crate::certificate::Certificate; +use crate::error::Error; +use crate::hooks::{self, FileStorageHookData}; +use log::trace; +use openssl::pkey::{PKey, Private, Public}; +use openssl::x509::X509; +use std::fmt; use std::fs::{File, OpenOptions}; -use std::io::prelude::*; +use std::io::{Read, Write}; use std::path::PathBuf; #[cfg(target_family = "unix")] use std::os::unix::fs::OpenOptionsExt; -macro_rules! get_file_name { - ($self: ident, $kind: ident, $fmt: ident) => {{ - let kind = match $kind { - PersistKind::Certificate => "crt", - PersistKind::PrivateKey => "pk", - PersistKind::AccountPrivateKey => "pk", +#[derive(Clone)] +enum FileType { + AccountPrivateKey, + AccountPublicKey, + PrivateKey, + Certificate, +} + +impl fmt::Display for FileType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = match self { + FileType::AccountPrivateKey => "priv-key", + FileType::AccountPublicKey => "pub-key", + FileType::PrivateKey => "pk", + FileType::Certificate => "crt", }; - format!( - // TODO: use self.crt_name_format instead of a string literal - "{name}_{algo}.{kind}.{ext}", - name = $self.crt_name, - algo = $self.algo.to_string(), - kind = kind, - ext = $fmt.to_string() - ) - }}; -} - -#[derive(Serialize)] -struct FileData { - file_name: String, - file_directory: String, - file_path: PathBuf, -} - -#[derive(Clone, Debug)] -pub struct Storage { - pub account_directory: String, - pub account_name: String, - pub crt_directory: String, - pub crt_name: String, - pub crt_name_format: String, - pub formats: Vec, - pub algo: Algorithm, - pub cert_file_mode: u32, - pub cert_file_owner: Option, - pub cert_file_group: Option, - pub pk_file_mode: u32, - pub pk_file_owner: Option, - pub pk_file_group: Option, - pub file_pre_create_hooks: Vec, - pub file_post_create_hooks: Vec, - pub file_pre_edit_hooks: Vec, - pub file_post_edit_hooks: Vec, -} - -impl Storage { - #[cfg(unix)] - fn get_file_mode(&self, kind: PersistKind) -> u32 { - match kind { - PersistKind::Certificate => self.cert_file_mode, - PersistKind::PrivateKey | PersistKind::AccountPrivateKey => self.pk_file_mode, - } + write!(f, "{}", s) } +} - #[cfg(unix)] - fn set_owner(&self, path: &PathBuf, kind: PersistKind) -> Result<(), Error> { - let (uid, gid) = match kind { - PersistKind::Certificate => (&self.cert_file_owner, &self.cert_file_group), - PersistKind::PrivateKey | PersistKind::AccountPrivateKey => { - (&self.pk_file_owner, &self.pk_file_group) - } - }; - let uid = match uid { - Some(u) => { - if u.bytes().all(|b| b.is_ascii_digit()) { - let raw_uid = u.parse::().unwrap(); - let nix_uid = nix::unistd::Uid::from_raw(raw_uid); - Some(nix_uid) - } else { - // TODO: handle username - None - } - } - None => None, - }; - let gid = match gid { - Some(g) => { - if g.bytes().all(|b| b.is_ascii_digit()) { - let raw_gid = g.parse::().unwrap(); - let nix_gid = nix::unistd::Gid::from_raw(raw_gid); - Some(nix_gid) - } else { - // TODO: handle group name - None - } - } - None => None, - }; - match nix::unistd::chown(path, uid, gid) { - Ok(_) => Ok(()), - Err(e) => Err(Error::Other(format!("{}", e))), +fn get_file_full_path( + cert: &Certificate, + file_type: FileType, +) -> Result<(String, String, PathBuf), Error> { + let base_path = match file_type { + FileType::AccountPrivateKey | FileType::AccountPublicKey => &cert.account_directory, + FileType::PrivateKey => &cert.crt_directory, + FileType::Certificate => &cert.crt_directory, + }; + let file_name = match file_type { + FileType::AccountPrivateKey | FileType::AccountPublicKey => format!( + "{email}.{file_type}.{ext}", + email = cert.email, + file_type = file_type.to_string(), + ext = "pem" + ), + FileType::PrivateKey | FileType::Certificate => { + // TODO: use cert.crt_name_format instead of a string literal + format!( + "{name}_{algo}.{file_type}.{ext}", + name = cert.crt_name, + algo = cert.algo.to_string(), + file_type = file_type.to_string(), + ext = "pem" + ) } - } + }; + let mut path = PathBuf::from(&base_path); + path.push(&file_name); + Ok((base_path.to_string(), file_name, path)) +} - fn get_file_path(&self, kind: PersistKind, fmt: &Format) -> FileData { - let base_path = match kind { - PersistKind::Certificate => &self.crt_directory, - PersistKind::PrivateKey => &self.crt_directory, - PersistKind::AccountPrivateKey => &self.account_directory, - }; - let file_name = match kind { - PersistKind::Certificate => get_file_name!(self, kind, fmt), - PersistKind::PrivateKey => get_file_name!(self, kind, fmt), - PersistKind::AccountPrivateKey => { - format!("{}.{}", self.account_name.to_owned(), fmt.to_string()) +fn get_file_path(cert: &Certificate, file_type: FileType) -> Result { + let (_, _, path) = get_file_full_path(cert, file_type)?; + Ok(path) +} + +fn read_file(path: &PathBuf) -> Result, Error> { + trace!("Reading file {:?}", path); + let mut file = File::open(path)?; + let mut contents = vec![]; + file.read_to_end(&mut contents)?; + Ok(contents) +} + +#[cfg(unix)] +fn set_owner(cert: &Certificate, path: &PathBuf, file_type: FileType) -> Result<(), Error> { + let (uid, gid) = match file_type { + FileType::Certificate => ( + cert.cert_file_owner.to_owned(), + cert.cert_file_group.to_owned(), + ), + FileType::PrivateKey => (cert.pk_file_owner.to_owned(), cert.pk_file_group.to_owned()), + FileType::AccountPrivateKey | FileType::AccountPublicKey => { + // The account private and public keys does not need to be accessible to users other different from the current one. + return Ok(()); + } + }; + let uid = match uid { + Some(u) => { + if u.bytes().all(|b| b.is_ascii_digit()) { + let raw_uid = u.parse::().unwrap(); + let nix_uid = nix::unistd::Uid::from_raw(raw_uid); + Some(nix_uid) + } else { + // TODO: handle username + None + } + } + None => None, + }; + let gid = match gid { + Some(g) => { + if g.bytes().all(|b| b.is_ascii_digit()) { + let raw_gid = g.parse::().unwrap(); + let nix_gid = nix::unistd::Gid::from_raw(raw_gid); + Some(nix_gid) + } else { + // TODO: handle group name + None } - }; - let mut path = PathBuf::from(base_path); - path.push(&file_name); - FileData { - file_directory: base_path.to_string(), - file_name, - file_path: path, } + None => None, + }; + match uid { + Some(u) => trace!("Setting the uid to {}", u.as_raw()), + None => trace!("Uid unchanged"), + }; + match gid { + Some(g) => trace!("Setting the gid to {}", g.as_raw()), + None => trace!("Gid unchanged"), + }; + match nix::unistd::chown(path, uid, gid) { + Ok(_) => Ok(()), + Err(e) => Err(format!("{}", e).into()), } +} + +fn write_file(cert: &Certificate, file_type: FileType, data: &[u8]) -> Result<(), Error> { + let (file_directory, file_name, path) = get_file_full_path(cert, file_type.clone())?; + let hook_data = FileStorageHookData { + file_name, + file_directory, + file_path: path.to_path_buf(), + }; + let is_new = !path.is_file(); - pub fn get_certificate(&self, fmt: &Format) -> Result>, Error> { - self.get_file(PersistKind::Certificate, fmt) + if is_new { + hooks::call_multiple(&hook_data, &cert.file_pre_create_hooks)?; + } else { + hooks::call_multiple(&hook_data, &cert.file_pre_edit_hooks)?; } - pub fn get_private_key(&self, fmt: &Format) -> Result>, Error> { - self.get_file(PersistKind::PrivateKey, fmt) + trace!("Writing file {:?}", path); + let mut file = if cfg!(unix) { + let mut options = OpenOptions::new(); + options.mode(match &file_type { + FileType::Certificate => cert.cert_file_mode, + FileType::PrivateKey => cert.pk_file_mode, + FileType::AccountPublicKey => crate::DEFAULT_ACCOUNT_FILE_MODE, + FileType::AccountPrivateKey => crate::DEFAULT_ACCOUNT_FILE_MODE, + }); + options.write(true).create(true).open(&path)? + } else { + File::create(&path)? + }; + file.write_all(data)?; + if cfg!(unix) { + set_owner(cert, &path, file_type)?; } - pub fn get_file(&self, kind: PersistKind, fmt: &Format) -> Result>, Error> { - let src_fmt = if self.formats.contains(fmt) { - fmt - } else { - self.formats.first().unwrap() - }; - let file_data = self.get_file_path(kind, src_fmt); - debug!("Reading file {:?}", file_data.file_path); - if !file_data.file_path.exists() { - return Ok(None); - } - let mut file = File::open(&file_data.file_path)?; - let mut contents = vec![]; - file.read_to_end(&mut contents)?; - if contents.is_empty() { - return Ok(None); - } - if src_fmt == fmt { - Ok(Some(contents)) - } else { - let ret = convert(&contents, src_fmt, fmt, kind)?; - Ok(Some(ret)) - } + if is_new { + hooks::call_multiple(&hook_data, &cert.file_post_create_hooks)?; + } else { + hooks::call_multiple(&hook_data, &cert.file_post_edit_hooks)?; } + Ok(()) } -impl Persist for Storage { - fn put(&self, key: &PersistKey, value: &[u8]) -> Result<(), Error> { - for fmt in self.formats.iter() { - let file_data = self.get_file_path(key.kind, &fmt); - debug!("Writing file {:?}", file_data.file_path); - let file_exists = file_data.file_path.exists(); - if file_exists { - hooks::call_multiple(&file_data, &self.file_pre_edit_hooks).map_err(to_acme_err)?; - } else { - hooks::call_multiple(&file_data, &self.file_pre_create_hooks) - .map_err(to_acme_err)?; - } - { - let mut f = if cfg!(unix) { - let mut options = OpenOptions::new(); - options.mode(self.get_file_mode(key.kind)); - options - .write(true) - .create(true) - .open(&file_data.file_path)? - } else { - File::create(&file_data.file_path)? - }; - match fmt { - Format::Der => { - let val = convert(value, &Format::Pem, &Format::Der, key.kind)?; - f.write_all(&val)?; - } - Format::Pem => f.write_all(value)?, - }; - f.sync_all()?; - } - if cfg!(unix) { - self.set_owner(&file_data.file_path, key.kind)?; - } - if file_exists { - hooks::call_multiple(&file_data, &self.file_post_edit_hooks) - .map_err(to_acme_err)?; - } else { - hooks::call_multiple(&file_data, &self.file_post_create_hooks) - .map_err(to_acme_err)?; +pub fn get_account_priv_key(cert: &Certificate) -> Result, Error> { + let path = get_file_path(cert, FileType::AccountPrivateKey)?; + let raw_key = read_file(&path)?; + let key = PKey::private_key_from_pem(&raw_key)?; + Ok(key) +} + +pub fn set_account_priv_key(cert: &Certificate, key: &PKey) -> Result<(), Error> { + let data = key.private_key_to_pem_pkcs8()?; + write_file(cert, FileType::AccountPrivateKey, &data) +} + +pub fn get_account_pub_key(cert: &Certificate) -> Result, Error> { + let path = get_file_path(cert, FileType::AccountPublicKey)?; + let raw_key = read_file(&path)?; + let key = PKey::public_key_from_pem(&raw_key)?; + Ok(key) +} + +pub fn set_account_pub_key(cert: &Certificate, key: &PKey) -> Result<(), Error> { + let data = key.public_key_to_pem()?; + write_file(cert, FileType::AccountPublicKey, &data) +} + +pub fn get_priv_key(cert: &Certificate) -> Result, Error> { + let path = get_file_path(cert, FileType::PrivateKey)?; + let raw_key = read_file(&path)?; + let key = PKey::private_key_from_pem(&raw_key)?; + Ok(key) +} + +pub fn set_priv_key(cert: &Certificate, key: &PKey) -> Result<(), Error> { + let data = key.private_key_to_pem_pkcs8()?; + write_file(cert, FileType::PrivateKey, &data) +} + +pub fn get_pub_key(cert: &Certificate) -> Result, Error> { + let pub_key = get_certificate(cert)?.public_key()?; + Ok(pub_key) +} + +pub fn get_certificate(cert: &Certificate) -> Result { + let path = get_file_path(cert, FileType::Certificate)?; + let raw_crt = read_file(&path)?; + let crt = X509::from_pem(&raw_crt)?; + Ok(crt) +} + +pub fn write_certificate(cert: &Certificate, data: &[u8]) -> Result<(), Error> { + write_file(cert, FileType::Certificate, data) +} + +fn check_files(cert: &Certificate, file_types: &[FileType]) -> bool { + for t in file_types.to_vec() { + let path = match get_file_path(cert, t) { + Ok(p) => p, + Err(_) => { + return false; } + }; + trace!("Testing file path: {}", path.to_str().unwrap()); + if !path.is_file() { + return false; } - Ok(()) } + true +} - fn get(&self, key: &PersistKey) -> Result>, Error> { - self.get_file(key.kind, &Format::Pem) - } +pub fn account_files_exists(cert: &Certificate) -> bool { + let file_types = vec![FileType::AccountPrivateKey, FileType::AccountPublicKey]; + check_files(cert, &file_types) } -fn to_acme_err(e: errors::Error) -> Error { - Error::Other(e.message) +pub fn certificate_files_exists(cert: &Certificate) -> bool { + let file_types = vec![FileType::PrivateKey, FileType::Certificate]; + check_files(cert, &file_types) }