diff --git a/Cargo.toml b/Cargo.toml index a623603..e146d1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ hyper = "1.3.1" http-body-util = "0.1.1" bytes = "1.6.0" hyper-util = "0.1.3" +axum-macros = "0.5.0" [package.metadata.docs.rs] all-features = true @@ -64,8 +65,12 @@ tokio = ["dep:tokio", "dep:tokio-util"] tls12 = ["async-web-client/tls12"] [[example]] -name="low_level_axum" -required-features=["axum"] +name = "low_level_axum" +required-features = ["axum"] + +[[example]] +name = "low_level_axum_http" +required-features = ["axum"] [[example]] name="high_level_warp" diff --git a/examples/low_level_axum_http.rs b/examples/low_level_axum_http.rs new file mode 100644 index 0000000..6b97525 --- /dev/null +++ b/examples/low_level_axum_http.rs @@ -0,0 +1,86 @@ +use axum::extract::{Path, State}; +use axum::response::{IntoResponse, Response}; +use axum::{routing::get, Router}; +use axum_macros::debug_handler; +use axum_server::bind; +use clap::Parser; +use http::{header, HeaderValue, StatusCode}; +use rustls_acme::caches::DirCache; +use rustls_acme::UseChallenge::Http01; +use rustls_acme::{AcmeConfig, ResolvesServerCertAcme}; +use std::net::{Ipv6Addr, SocketAddr}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio_stream::StreamExt; + +#[derive(Parser, Debug)] +struct Args { + /// Domains + #[clap(short, required = true)] + domains: Vec, + + /// Contact info + #[clap(short)] + email: Vec, + + /// Cache directory + #[clap(short, parse(from_os_str))] + cache: Option, + + /// Use Let's Encrypt production environment + /// (see https://letsencrypt.org/docs/staging-environment/) + #[clap(long)] + prod: bool, + + #[clap(short, long, default_value = "443")] + port: u16, +} + +#[tokio::main] +async fn main() { + simple_logger::init_with_level(log::Level::Info).unwrap(); + let args = Args::parse(); + + let mut state = AcmeConfig::new(args.domains) + .contact(args.email.iter().map(|e| format!("mailto:{}", e))) + .cache_option(args.cache.clone().map(DirCache::new)) + .directory_lets_encrypt(args.prod) + .challenge_type(Http01) + .state(); + let acceptor = state.axum_acceptor(state.default_rustls_config()); + + let http_challenge_app = Router::new() + .route("/.well-known/acme-challenge/{challenge_token}/", get(http01_challenge)) + .with_state(state.resolver().clone()); + tokio::spawn(challenge_http_app(http_challenge_app)); + + tokio::spawn(async move { + loop { + match state.next().await.unwrap() { + Ok(ok) => log::info!("event: {:?}", ok), + Err(err) => log::error!("error: {:?}", err), + } + } + }); + + let app = Router::new().route("/", get(|| async { "Hello Tls!" })); + let addr = SocketAddr::from((Ipv6Addr::UNSPECIFIED, args.port)); + bind(addr).acceptor(acceptor).serve(app.into_make_service()).await.unwrap(); +} + +async fn challenge_http_app(http_challenge_app: Router) { + let listener = tokio::net::TcpListener::bind((Ipv6Addr::UNSPECIFIED, 80)).await.unwrap(); + axum::serve(listener, http_challenge_app.into_make_service()).await.unwrap(); +} + +async fn http01_challenge(State(resolver): State>, Path(challenge_token): Path) -> Response { + match resolver.get_key_auth(&challenge_token) { + None => (StatusCode::NOT_FOUND,).into_response(), + Some(key_auth) => ( + StatusCode::OK, + [(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"))], + key_auth, + ) + .into_response(), + } +} diff --git a/src/acme.rs b/src/acme.rs index 0b611d9..8a7bcac 100644 --- a/src/acme.rs +++ b/src/acme.rs @@ -1,11 +1,9 @@ -use std::sync::Arc; - use crate::any_ecdsa_type; use crate::crypto::error::{KeyRejected, Unspecified}; use crate::crypto::rand::SystemRandom; use crate::crypto::signature::{EcdsaKeyPair, EcdsaSigningAlgorithm, ECDSA_P256_SHA256_FIXED_SIGNING}; use crate::https_helper::{https, HttpsRequestError}; -use crate::jose::{key_authorization_sha256, sign, JoseError}; +use crate::jose::{key_authorization, key_authorization_sha256, sign, JoseError}; use base64::prelude::*; use futures_rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; use futures_rustls::rustls::{sign::CertifiedKey, ClientConfig}; @@ -14,6 +12,7 @@ use http::{Method, Response}; use rcgen::{CustomExtension, KeyPair, PKCS_ECDSA_P256_SHA256}; use serde::{Deserialize, Serialize}; use serde_json::json; +use std::sync::Arc; use thiserror::Error; pub const LETS_ENCRYPT_STAGING_DIRECTORY: &str = "https://acme-staging-v02.api.letsencrypt.org/directory"; @@ -130,6 +129,15 @@ impl Account { let certified_key = CertifiedKey::new(vec![cert.der().clone()], sk); Ok((challenge, certified_key)) } + pub fn http_01<'a>(&self, challenges: &'a [Challenge]) -> Result<(&'a Challenge, String), AcmeError> { + let challenge = challenges.iter().find(|c| c.typ == ChallengeType::Http01); + let challenge = match challenge { + Some(challenge) => challenge, + None => return Err(AcmeError::NoHttp01Challenge), + }; + let key_auth = key_authorization(&self.key_pair, &challenge.token)?; + Ok((challenge, key_auth)) + } } #[derive(Debug, Clone, Deserialize)] @@ -245,6 +253,8 @@ pub enum AcmeError { MissingHeader(&'static str), #[error("no tls-alpn-01 challenge found")] NoTlsAlpn01Challenge, + #[error("no http-01 challenge found")] + NoHttp01Challenge, } impl From for AcmeError { diff --git a/src/config.rs b/src/config.rs index 637e1e2..c418ea7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ use crate::acme::{LETS_ENCRYPT_PRODUCTION_DIRECTORY, LETS_ENCRYPT_STAGING_DIRECTORY}; use crate::caches::{BoxedErrCache, CompositeCache, NoCache}; +use crate::UseChallenge::TlsAlpn01; use crate::{crypto_provider, AccountCache, Cache, CertCache}; use crate::{AcmeState, Incoming}; use core::fmt; @@ -21,6 +22,12 @@ pub struct AcmeConfig { pub(crate) domains: Vec, pub(crate) contact: Vec, pub(crate) cache: Box>, + pub(crate) challenge_type: UseChallenge, +} + +pub enum UseChallenge { + Http01, + TlsAlpn01, } impl AcmeConfig { @@ -78,6 +85,7 @@ impl AcmeConfig { domains: domains.into_iter().map(|s| s.as_ref().into()).collect(), contact: vec![], cache: Box::new(NoCache::default()), + challenge_type: TlsAlpn01, } } } @@ -132,6 +140,7 @@ impl AcmeConfig { domains: self.domains, contact: self.contact, cache: Box::new(cache), + challenge_type: self.challenge_type, } } pub fn cache_compose(self, cert_cache: CC, account_cache: CA) -> AcmeConfig { @@ -146,6 +155,10 @@ impl AcmeConfig { None => self.cache(NoCache::::default()), } } + pub fn challenge_type(mut self, challenge_type: UseChallenge) -> Self { + self.challenge_type = challenge_type; + self + } pub fn state(self) -> AcmeState { AcmeState::new(self) } diff --git a/src/jose.rs b/src/jose.rs index eb519ba..98aa3b5 100644 --- a/src/jose.rs +++ b/src/jose.rs @@ -23,9 +23,13 @@ pub(crate) fn sign(key: &EcdsaKeyPair, kid: Option<&str>, nonce: String, url: &s Ok(serde_json::to_string(&body)?) } -pub(crate) fn key_authorization_sha256(key: &EcdsaKeyPair, token: &str) -> Result { +pub(crate) fn key_authorization(key: &EcdsaKeyPair, token: &str) -> Result { let jwk = Jwk::new(key); - let key_authorization = format!("{}.{}", token, jwk.thumb_sha256_base64()?); + Ok(format!("{}.{}", token, jwk.thumb_sha256_base64()?)) +} + +pub(crate) fn key_authorization_sha256(key: &EcdsaKeyPair, token: &str) -> Result { + let key_authorization = key_authorization(key, token)?; Ok(digest(&SHA256, key_authorization.as_bytes())) } diff --git a/src/resolver.rs b/src/resolver.rs index f8928fc..db5ad34 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -1,13 +1,12 @@ +use crate::is_tls_alpn_challenge; use futures_rustls::rustls::{ server::{ClientHello, ResolvesServerCert}, sign::CertifiedKey, }; -use std::collections::BTreeMap; +use std::fmt::Debug; use std::sync::Arc; use std::sync::Mutex; -use crate::is_tls_alpn_challenge; - #[derive(Debug)] pub struct ResolvesServerCertAcme { inner: Mutex, @@ -16,7 +15,13 @@ pub struct ResolvesServerCertAcme { #[derive(Debug)] struct Inner { cert: Option>, - auth_keys: BTreeMap>, + challenge_data: Option, +} + +#[derive(Debug)] +enum ChallengeData { + TlsAlpn01 { sni: String, cert: Arc }, + Http01 { token: String, key_auth: String }, } impl ResolvesServerCertAcme { @@ -24,15 +29,33 @@ impl ResolvesServerCertAcme { Arc::new(Self { inner: Mutex::new(Inner { cert: None, - auth_keys: Default::default(), + challenge_data: None, }), }) } pub(crate) fn set_cert(&self, cert: Arc) { self.inner.lock().unwrap().cert = Some(cert); } - pub(crate) fn set_auth_key(&self, domain: String, cert: Arc) { - self.inner.lock().unwrap().auth_keys.insert(domain, cert); + pub(crate) fn set_tls_alpn_01_challenge_data(&self, domain: String, cert: Arc) { + self.inner.lock().unwrap().challenge_data = Some(ChallengeData::TlsAlpn01 { sni: domain, cert }); + } + pub(crate) fn set_http_01_challenge_data(&self, token: String, key_auth: String) { + self.inner.lock().unwrap().challenge_data = Some(ChallengeData::Http01 { token, key_auth }) + } + pub(crate) fn clear_challenge_data(&self) { + self.inner.lock().unwrap().challenge_data = None; + } + pub fn get_key_auth(&self, challenge_token: &String) -> Option { + match &self.inner.lock().unwrap().challenge_data { + Some(ChallengeData::Http01 { token, key_auth }) => { + if token.eq(challenge_token) { + Some(key_auth.clone()) + } else { + None + } + } + _ => None, + } } } @@ -48,7 +71,16 @@ impl ResolvesServerCert for ResolvesServerCertAcme { Some(domain) => { let domain = domain.to_owned(); let domain: String = AsRef::::as_ref(&domain).into(); - self.inner.lock().unwrap().auth_keys.get(&domain).cloned() + match &self.inner.lock().unwrap().challenge_data { + Some(ChallengeData::TlsAlpn01 { sni, cert }) => { + if domain.eq(sni) { + Some(cert.clone()) + } else { + None + } + } + _ => None, + } } } } else { diff --git a/src/state.rs b/src/state.rs index a69c30a..98bedc9 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,10 +1,9 @@ use crate::acceptor::AcmeAcceptor; use crate::acme::{Account, AcmeError, Auth, AuthStatus, Directory, Identifier, Order, OrderStatus, ACME_TLS_ALPN_NAME}; -use crate::{any_ecdsa_type, crypto_provider, AcmeConfig, Incoming, ResolvesServerCertAcme}; +use crate::{any_ecdsa_type, crypto_provider, AcmeConfig, Incoming, ResolvesServerCertAcme, UseChallenge}; use async_io::Timer; use chrono::{DateTime, TimeZone, Utc}; use core::fmt; -use futures::future::try_join_all; use futures::prelude::*; use futures::ready; use futures_rustls::pki_types::{CertificateDer as RustlsCertificate, PrivateKeyDer, PrivatePkcs8KeyDer}; @@ -254,8 +253,10 @@ impl AcmeState { loop { match order.status { OrderStatus::Pending => { - let auth_futures = order.authorizations.iter().map(|url| Self::authorize(&config, &resolver, &account, url)); - try_join_all(auth_futures).await?; + // Force in order authorizations to allow single global challenge data state + for url in order.authorizations.iter() { + Self::authorize(&config, &resolver, &account, url).await? + } log::info!("completed all authorizations"); order = account.order(&config.client_config, &order_url).await?; } @@ -296,13 +297,31 @@ impl AcmeState { AuthStatus::Pending => { let Identifier::Dns(domain) = auth.identifier; log::info!("trigger challenge for {}", &domain); - let (challenge, auth_key) = account.tls_alpn_01(&auth.challenges, domain.clone())?; - resolver.set_auth_key(domain.clone(), Arc::new(auth_key)); + let challenge = match config.challenge_type { + UseChallenge::Http01 => { + let (challenge, key_auth) = account.http_01(&auth.challenges)?; + resolver.set_http_01_challenge_data(challenge.token.clone(), key_auth); + challenge + } + UseChallenge::TlsAlpn01 => { + let (challenge, auth_key) = account.tls_alpn_01(&auth.challenges, domain.clone())?; + resolver.set_tls_alpn_01_challenge_data(domain.clone(), Arc::new(auth_key)); + challenge + } + }; account.challenge(&config.client_config, &challenge.url).await?; (domain, challenge.url.clone()) } - AuthStatus::Valid => return Ok(()), - _ => return Err(OrderError::BadAuth(auth)), + AuthStatus::Valid => { + // clear challenge data when auth validated + resolver.clear_challenge_data(); + return Ok(()); + } + _ => { + // clear challenge data when auth invalidated + resolver.clear_challenge_data(); + return Err(OrderError::BadAuth(auth)); + } }; for i in 0u64..5 { Timer::after(Duration::from_secs(1u64 << i)).await; @@ -312,8 +331,16 @@ impl AcmeState { log::info!("authorization for {} still pending", &domain); account.challenge(&config.client_config, &challenge_url).await? } - AuthStatus::Valid => return Ok(()), - _ => return Err(OrderError::BadAuth(auth)), + AuthStatus::Valid => { + // clear challenge data when auth validated + resolver.clear_challenge_data(); + return Ok(()); + } + _ => { + // clear challenge data when auth invalidated + resolver.clear_challenge_data(); + return Err(OrderError::BadAuth(auth)); + } } } Err(OrderError::TooManyAttemptsAuth(domain))