diff --git a/README.md b/README.md index 0a4e3fd..f1af620 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,15 @@ certificate management (ACMEv2) protocol. The module implements following specifications: - * [RFC8555] (Automatic Certificate Management Environment) with limitations: - * Only HTTP-01 challenge type is supported - * External account binding is not supported +- [RFC8555] (Automatic Certificate Management Environment) with limitations: + - Only HTTP-01 challenge type is supported + - External account binding is not supported +- [RFC8737] (ACME TLS Application-Layer Protocol Negotiation (ALPN) Challenge + Extension) [NGINX]: https://nginx.org/ [RFC8555]: https://www.rfc-editor.org/rfc/rfc8555.html +[RFC8737]: https://www.rfc-editor.org/rfc/rfc8737.html ## Getting Started @@ -166,6 +169,19 @@ Accepted values: The generated account keys are preserved across reloads, but will be lost on restart unless [state_path](#state_path) is configured. +### challenge + +**Syntax:** challenge `type` + +**Default:** http-01 + +**Context:** acme_issuer + +Sets challenge type used for this issuer. Allowed values: + +- `http-01` +- `tls-alpn-01` + ### contact **Syntax:** contact `url` diff --git a/src/acme.rs b/src/acme.rs index 0a8104d..785eb45 100644 --- a/src/acme.rs +++ b/src/acme.rs @@ -41,6 +41,7 @@ pub struct NewCertificateOutput { pub struct AuthorizationContext<'a> { pub thumbprint: &'a [u8], + pub pkey: &'a PKeyRef, } pub struct AcmeClient<'a, Http> @@ -332,6 +333,7 @@ where let order = AuthorizationContext { thumbprint: self.key.thumbprint(), + pkey: &pkey, }; for (url, authorization) in authorizations { diff --git a/src/acme/solvers.rs b/src/acme/solvers.rs index 23bc43d..2c6d5ec 100644 --- a/src/acme/solvers.rs +++ b/src/acme/solvers.rs @@ -10,6 +10,7 @@ use super::AuthorizationContext; use crate::conf::identifier::Identifier; pub mod http; +pub mod tls_alpn; #[derive(Debug, Error)] #[error("challenge registration failed: {0}")] diff --git a/src/acme/solvers/tls_alpn.rs b/src/acme/solvers/tls_alpn.rs new file mode 100644 index 0000000..9582bed --- /dev/null +++ b/src/acme/solvers/tls_alpn.rs @@ -0,0 +1,416 @@ +// Copyright (c) F5, Inc. +// +// This source code is licensed under the Apache License, Version 2.0 license found in the +// LICENSE file in the root directory of this source tree. + +use core::ffi::{c_int, c_uint, c_void, CStr}; +use core::{ptr, slice}; + +use nginx_sys::{ngx_conf_t, NGX_LOG_ERR}; +use ngx::allocator::Allocator; +use ngx::collections::RbTreeMap; +use ngx::core::{NgxString, SlabPool, Status}; +use ngx::http::HttpModuleServerConf; +use ngx::sync::RwLock; +use ngx::{ngx_log_debug, ngx_log_error}; +use openssl::asn1::Asn1Time; +use openssl::error::ErrorStack; +use openssl::hash::MessageDigest; +use openssl::pkey::{PKey, Private}; +use openssl::x509::{self, extension as x509_ext, X509}; +use openssl_foreign_types::ForeignType; +use openssl_sys::SSL_CTX_set_cert_cb; +use openssl_sys::{ + SSL_CTX_set_alpn_select_cb__fixed_rust as SSL_CTX_set_alpn_select_cb, SSL_get_ex_data, +}; +use openssl_sys::{SSL_TLSEXT_ERR_ALERT_FATAL, SSL_TLSEXT_ERR_OK}; +use zeroize::{Zeroize, Zeroizing}; + +use crate::acme; +use crate::acme::types::ChallengeKind; +use crate::conf::identifier::Identifier; +use crate::conf::AcmeMainConfig; + +use super::{ChallengeSolver, SolverError}; + +/// `openssl-sys` does not publish these constants. +#[allow(non_upper_case_globals)] +const TLSEXT_TYPE_application_layer_protocol_negotiation: c_uint = 16; + +/// Registers tls-alpn-01 in the server merge configuration handler. +pub fn merge_srv_conf(cf: &mut ngx_conf_t, amcf: &mut AcmeMainConfig) -> Result<(), Status> { + let sscf = ngx::http::NgxHttpSslModule::server_conf(cf).expect("ssl server conf"); + if sscf.ssl.ctx.is_null() { + return Ok(()); + } + + unsafe { + openssl_sys::SSL_CTX_set_client_hello_cb( + sscf.ssl.ctx.cast(), + Some(ssl_client_hello_cb), + ptr::from_mut(amcf).cast(), + ) + }; + + Ok(()) +} + +/// Registers tls-alpn-01 challenge handler. +pub fn postconfiguration(_cf: &mut ngx_conf_t, amcf: &mut AcmeMainConfig) -> Result<(), Status> { + let amcfp: *mut c_void = ptr::from_mut(amcf).cast(); + + amcf.ssl.init(amcfp)?; + + let ssl_ctx: *mut openssl_sys::SSL_CTX = amcf.ssl.as_ref().ctx.cast(); + + unsafe { SSL_CTX_set_cert_cb(ssl_ctx, Some(ssl_cert_cb), amcfp) }; + unsafe { SSL_CTX_set_alpn_select_cb(ssl_ctx, Some(ssl_alpn_select_cb), ptr::null_mut()) }; + + Ok(()) +} + +pub type TlsAlpn01SolverState = RbTreeMap, TlsAlpn01Response, A>; + +#[derive(Debug)] +pub struct TlsAlpn01Solver<'a>(&'a RwLock>); + +#[derive(Debug)] +pub struct TlsAlpn01Response +where + A: Allocator + Clone, +{ + pub key_authorization: NgxString, + pub pkey: NgxString, +} + +impl Drop for TlsAlpn01Response +where + A: Allocator + Clone, +{ + fn drop(&mut self) { + let bytes: &mut [u8] = self.pkey.as_mut(); + bytes.zeroize(); + } +} + +impl<'a> TlsAlpn01Solver<'a> { + pub fn new(inner: &'a RwLock>) -> Self { + Self(inner) + } +} + +impl ChallengeSolver for TlsAlpn01Solver<'_> { + fn supports(&self, c: &ChallengeKind) -> bool { + matches!(c, ChallengeKind::TlsAlpn01) + } + + fn register( + &self, + ctx: &acme::AuthorizationContext, + identifier: &Identifier<&str>, + challenge: &acme::types::Challenge, + ) -> Result<(), SolverError> { + let alloc = self.0.read().allocator().clone(); + + let mut key_authorization = NgxString::new_in(alloc.clone()); + key_authorization.try_reserve_exact(challenge.token.len() + ctx.thumbprint.len() + 1)?; + // write to a preallocated buffer of a sufficient size should succeed + let _ = key_authorization.append_within_capacity(challenge.token.as_bytes()); + let _ = key_authorization.append_within_capacity(b"."); + let _ = key_authorization.append_within_capacity(ctx.thumbprint); + let pkey = Zeroizing::new(ctx.pkey.private_key_to_pem_pkcs8()?); + let pkey = NgxString::try_from_bytes_in(pkey, alloc.clone())?; + let resp = TlsAlpn01Response { + key_authorization, + pkey, + }; + let servername = NgxString::try_from_bytes_in(identifier.value(), alloc.clone())?; + self.0.write().try_insert(servername, resp)?; + Ok(()) + } + + fn unregister( + &self, + identifier: &Identifier<&str>, + _challenge: &acme::types::Challenge, + ) -> Result<(), SolverError> { + self.0.write().remove(identifier.value().as_bytes()); + Ok(()) + } +} + +struct TlsAlpnIter<'a>(&'a [u8]); + +impl<'a> TlsAlpnIter<'a> { + pub fn new(buf: &'a [u8]) -> Option> { + let (len, buf) = buf.split_first_chunk::<2>()?; + + if buf.len() < u16::from_be_bytes(*len).into() { + return None; + } + + Some(Self(buf)) + } +} + +impl<'a> Iterator for TlsAlpnIter<'a> { + type Item = &'a [u8]; + + fn next(&mut self) -> Option { + let (len, mut buf) = self.0.split_first_chunk::<1>()?; + + let len = u8::from_be_bytes(*len) as usize; + if buf.len() < len { + return None; // error? + } + + (buf, self.0) = buf.split_at(len); + + Some(buf) + } +} + +fn acme_ssl_get_alpn(ssl: &mut openssl_sys::SSL) -> Option<&[u8]> { + let mut p: *const core::ffi::c_uchar = ptr::null_mut(); + let mut len = 0usize; + + let rc = unsafe { + openssl_sys::SSL_client_hello_get0_ext( + ssl, + TLSEXT_TYPE_application_layer_protocol_negotiation, + ptr::addr_of_mut!(p), + &mut len, + ) + }; + + // XXX: len == 0 is an obvious error + if rc == 0 || len == 0 { + return None; + } + + Some(unsafe { slice::from_raw_parts(p, len) }) +} + +unsafe extern "C" fn ssl_client_hello_cb( + ssl: *mut openssl_sys::SSL, + _alert: *mut c_int, + data: *mut c_void, +) -> c_int { + use openssl_sys::{SSL_CLIENT_HELLO_ERROR, SSL_CLIENT_HELLO_SUCCESS}; + + let c: *mut ngx::ffi::ngx_connection_t = + SSL_get_ex_data(ssl, ngx::ffi::ngx_ssl_connection_index).cast(); + + let Some(alpn) = acme_ssl_get_alpn(&mut *ssl) else { + return SSL_CLIENT_HELLO_SUCCESS; + }; + + let Some(mut iter) = TlsAlpnIter::new(alpn) else { + ngx_log_error!(ngx::ffi::NGX_LOG_ERR, (*c).log, "invalid alpn"); + return SSL_CLIENT_HELLO_ERROR; + }; + + if iter.any(|x| x == b"acme-tls/1") { + let Some(amcf) = data.cast::().as_mut() else { + return SSL_CLIENT_HELLO_ERROR; + }; + + if !ssl_switch_context(&mut *ssl, amcf.ssl.as_ref()) { + ngx_log_error!( + ngx::ffi::NGX_LOG_ERR, + (*c).log, + "acme: failed to switch SslContext" + ); + return SSL_CLIENT_HELLO_ERROR; + } + } + + SSL_CLIENT_HELLO_SUCCESS +} + +unsafe fn ssl_switch_context(ssl_conn: &mut openssl_sys::SSL, ssl: &nginx_sys::ngx_ssl_t) -> bool { + use openssl_sys::{ + SSL_CTX_get_options, SSL_CTX_get_verify_callback, SSL_CTX_get_verify_mode, + SSL_clear_options, SSL_get_options, SSL_set_SSL_CTX, SSL_set_options, SSL_set_verify, + }; + + if ssl.ctx.is_null() { + return false; + } + let ssl_ctx: *mut openssl_sys::SSL_CTX = ssl.ctx.cast(); + + if SSL_set_SSL_CTX(ssl_conn, ssl_ctx).is_null() { + return false; + } + + SSL_set_verify( + ssl_conn, + SSL_CTX_get_verify_mode(ssl_ctx), + SSL_CTX_get_verify_callback(ssl_ctx), + ); + + SSL_clear_options( + ssl_conn, + SSL_get_options(ssl_conn) & !SSL_CTX_get_options(ssl_ctx), + ); + SSL_set_options(ssl_conn, SSL_CTX_get_options(ssl_ctx)); + SSL_set_options(ssl_conn, openssl::ssl::SslOptions::NO_RENEGOTIATION.bits()); + + true +} + +unsafe extern "C" fn ssl_cert_cb(ssl: *mut openssl_sys::SSL, data: *mut c_void) -> c_int { + use openssl_sys::{SSL_get_servername, SSL_use_PrivateKey, SSL_use_certificate}; + + let amcf: *mut AcmeMainConfig = data.cast(); + + let Some(mut c) = ptr::NonNull::::new( + SSL_get_ex_data(ssl, ngx::ffi::ngx_ssl_connection_index).cast(), + ) else { + return 0; + }; + let log = c.as_ref().log; + + let name = SSL_get_servername(ssl, openssl_sys::TLSEXT_NAMETYPE_host_name as _); + if name.is_null() { + ngx_log_error!(NGX_LOG_ERR, log, "acme/tls-alpn-01: missing server name"); + return 0; + } + + let name = CStr::from_ptr(name); + let Ok(name) = name.to_str() else { + ngx_log_error!( + NGX_LOG_ERR, + log, + "acme/tls-alpn-01: invalid server name: {name:?}" + ); + return 0; + }; + + let Some(amsh) = (*amcf).data else { + return 0; + }; + + let (pkey, auth) = if let Some(resp) = amsh.tls_alpn_01_state.read().get(name.as_bytes()) { + (resp.pkey.clone(), resp.key_authorization.clone()) + } else { + ngx_log_debug!(log, "acme/tls-alpn-01: no challenge registered for {name}",); + return 0; + }; + + ngx_log_debug!(log, "acme/tls-alpn-01: challenge for {name}"); + + let mut hasher = openssl::sha::Sha256::new(); + hasher.update(auth.as_bytes()); + let auth = hasher.finish(); + + let id = Identifier::Dns(name); + // XXX: fallback to key generation + let pkey = match PKey::private_key_from_pem(pkey.as_bytes()) { + Ok(pkey) => pkey, + Err(err) => { + ngx_log_error!(NGX_LOG_ERR, log, "acme/tls-alpn-01: handler failed: {err}"); + return 0; + } + }; + + let Ok(cert) = make_challenge_cert(&id, &auth, &pkey) else { + return 0; + }; + + if SSL_use_certificate(ssl, cert.as_ptr()) != 1 { + return 0; + } + + if SSL_use_PrivateKey(ssl, pkey.as_ptr()) != 1 { + return 0; + } + + c.as_mut().set_close(1); + 1 +} + +extern "C" fn ssl_alpn_select_cb( + _ssl: *mut openssl_sys::SSL, + out: *mut *const u8, + outlen: *mut u8, + r#in: *const u8, + inlen: core::ffi::c_uint, + _data: *mut c_void, +) -> c_int { + let srv = b"\x00\x0aacme-tls/1"; + + match unsafe { + openssl_sys::SSL_select_next_proto( + out as _, + outlen, + srv.as_ptr(), + srv.len() as _, + r#in, + inlen, + ) + } { + openssl_sys::OPENSSL_NPN_NEGOTIATED => SSL_TLSEXT_ERR_OK, + _ => SSL_TLSEXT_ERR_ALERT_FATAL as _, + } +} + +pub fn make_challenge_cert( + identifier: &Identifier<&str>, + key_authorization: &[u8], + pkey: &PKey, +) -> Result { + let mut x509_name = x509::X509NameBuilder::new()?; + x509_name.append_entry_by_text("CN", identifier.value())?; + let x509_name = x509_name.build(); + + let mut cert_builder = X509::builder()?; + cert_builder.set_version(2)?; + cert_builder.set_subject_name(&x509_name)?; + cert_builder.set_issuer_name(&x509_name)?; + cert_builder.set_pubkey(pkey)?; + let not_before = Asn1Time::days_from_now(0)?; + cert_builder.set_not_before(¬_before)?; + let not_after = Asn1Time::days_from_now(30)?; + cert_builder.set_not_after(¬_after)?; + + cert_builder.append_extension(x509_ext::BasicConstraints::new().build()?)?; + cert_builder.append_extension( + x509_ext::KeyUsage::new() + .critical() + .digital_signature() + .key_cert_sign() + .build()?, + )?; + let subject_key_identifier = + x509_ext::SubjectKeyIdentifier::new().build(&cert_builder.x509v3_context(None, None))?; + cert_builder.append_extension(subject_key_identifier)?; + + let mut subject_alt_name = x509_ext::SubjectAlternativeName::new(); + match identifier { + Identifier::Dns(name) => { + subject_alt_name.dns(name); + } + Identifier::Ip(addr) => { + subject_alt_name.ip(addr); + } + _ => panic!("unsupported identifier: {identifier:?}"), + }; + let subject_alt_name = subject_alt_name.build(&cert_builder.x509v3_context(None, None))?; + cert_builder.append_extension(subject_alt_name)?; + + /* RFC8737 Section 6.1, id-pe-acmeIdentifier */ + let oid = openssl::asn1::Asn1Object::from_str("1.3.6.1.5.5.7.1.31")?; + + let mut digest = [0u8; 0x22]; + digest[0] = 0x04; // ASN1_OCTET_STRING + digest[1] = 0x20; + digest[2..].copy_from_slice(key_authorization); + let digest = openssl::asn1::Asn1OctetString::new_from_bytes(&digest)?; + + let acme_identifier = x509::X509Extension::new_from_der(&oid, true, &digest)?; + cert_builder.append_extension(acme_identifier)?; + + cert_builder.sign(pkey, MessageDigest::sha256())?; + Ok(cert_builder.build()) +} diff --git a/src/conf.rs b/src/conf.rs index 3792056..89621b7 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -22,6 +22,8 @@ use self::issuer::Issuer; use self::order::CertificateOrder; use self::pkey::PrivateKey; use self::shared_zone::{SharedZone, ACME_ZONE_NAME, ACME_ZONE_SIZE}; +use self::ssl::NgxSsl; +use crate::acme::types::ChallengeKind; use crate::state::AcmeSharedData; pub mod ext; @@ -38,6 +40,7 @@ const NGX_CONF_DUPLICATE: *mut c_char = c"is duplicate".as_ptr().cast_mut(); #[derive(Debug, Default)] pub struct AcmeMainConfig { pub issuers: Vec, + pub ssl: NgxSsl, pub data: Option<&'static AcmeSharedData>, pub shm_zone: shared_zone::SharedZone, } @@ -79,7 +82,7 @@ pub static mut NGX_HTTP_ACME_COMMANDS: [ngx_command_t; 4] = [ ngx_command_t::empty(), ]; -static mut NGX_HTTP_ACME_ISSUER_COMMANDS: [ngx_command_t; 8] = [ +static mut NGX_HTTP_ACME_ISSUER_COMMANDS: [ngx_command_t; 9] = [ ngx_command_t { name: ngx_string!("uri"), type_: NGX_CONF_TAKE1 as ngx_uint_t, @@ -96,6 +99,14 @@ static mut NGX_HTTP_ACME_ISSUER_COMMANDS: [ngx_command_t; 8] = [ offset: 0, post: ptr::null_mut(), }, + ngx_command_t { + name: ngx_string!("challenge"), + type_: NGX_CONF_TAKE1 as ngx_uint_t, + set: Some(cmd_issuer_set_challenge), + conf: 0, + offset: 0, + post: ptr::null_mut(), + }, ngx_command_t { name: ngx_string!("contact"), type_: NGX_CONF_TAKE1 as ngx_uint_t, @@ -304,6 +315,35 @@ extern "C" fn cmd_add_certificate( NGX_CONF_OK } +extern "C" fn cmd_issuer_set_challenge( + cf: *mut ngx_conf_t, + _cmd: *mut ngx_command_t, + conf: *mut c_void, +) -> *mut c_char { + let cf = unsafe { cf.as_mut().expect("cf") }; + let issuer = unsafe { conf.cast::().as_mut().expect("issuer conf") }; + + if issuer.challenge.is_some() { + return NGX_CONF_DUPLICATE; + } + + // NGX_CONF_TAKE1 ensures that args contains 2 elements + let args = cf.args(); + + let Ok(val) = core::str::from_utf8(args[1].as_bytes()) else { + return NGX_CONF_ERROR; + }; + let val = ChallengeKind::from(val); + if !matches!(val, ChallengeKind::Http01 | ChallengeKind::TlsAlpn01) { + ngx_conf_log_error!(NGX_LOG_EMERG, cf, "unsupported challenge type: {val:?}"); + return NGX_CONF_ERROR; + }; + + issuer.challenge = Some(val); + + NGX_CONF_OK +} + extern "C" fn cmd_issuer_add_contact( cf: *mut ngx_conf_t, _cmd: *mut ngx_command_t, diff --git a/src/conf/issuer.rs b/src/conf/issuer.rs index 00f104b..340f36b 100644 --- a/src/conf/issuer.rs +++ b/src/conf/issuer.rs @@ -28,6 +28,7 @@ use super::order::CertificateOrder; use super::pkey::PrivateKey; use super::ssl::NgxSsl; use super::AcmeMainConfig; +use crate::acme::types::ChallengeKind; use crate::state::certificate::{CertificateContext, CertificateContextInner}; use crate::state::issuer::{IssuerContext, IssuerState}; use crate::time::{Time, TimeRange}; @@ -43,6 +44,7 @@ pub struct Issuer { pub name: ngx_str_t, pub uri: Uri, pub account_key: PrivateKey, + pub challenge: Option, pub contacts: Vec, pub resolver: Option>, pub resolver_timeout: ngx_msec_t, @@ -87,6 +89,7 @@ impl Issuer { name, uri: Default::default(), account_key: PrivateKey::Unset, + challenge: None, contacts: Vec::new_in(alloc.clone()), resolver: None, resolver_timeout: NGX_CONF_UNSET_MSEC, @@ -124,6 +127,10 @@ impl Issuer { self.account_key = PrivateKey::default(); } + if self.challenge.is_none() { + self.challenge = Some(ChallengeKind::Http01); + } + self.pkey = Some(self.try_init_account_key(cf)?); if self.ssl_verify == NGX_CONF_UNSET_FLAG { diff --git a/src/lib.rs b/src/lib.rs index 222f308..4422d07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ #![no_std] extern crate std; +use core::ffi::{c_char, c_void}; use core::time::Duration; use core::{cmp, ptr}; @@ -14,8 +15,8 @@ use nginx_sys::{ ngx_uint_t, NGX_HTTP_MODULE, NGX_LOG_ERR, NGX_LOG_INFO, NGX_LOG_NOTICE, NGX_LOG_WARN, }; use ngx::allocator::AllocError; -use ngx::core::Status; -use ngx::http::{HttpModule, HttpModuleMainConf, HttpModuleServerConf}; +use ngx::core::{Status, NGX_CONF_ERROR, NGX_CONF_OK}; +use ngx::http::{HttpModule, HttpModuleMainConf, HttpModuleServerConf, Merge}; use ngx::log::ngx_cycle_log; use ngx::{ngx_log_debug, ngx_log_error}; use openssl::x509::X509; @@ -101,6 +102,32 @@ impl HttpModule for HttpAcmeModule { Status::NGX_OK.into() } + unsafe extern "C" fn merge_srv_conf( + cf: *mut ngx_conf_t, + prev: *mut c_void, + conf: *mut c_void, + ) -> *mut c_char + where + Self: HttpModuleServerConf, + ::ServerConf: Merge, + { + let prev = &*prev.cast::(); + let conf = &mut *conf.cast::(); + + if conf.merge(prev).is_err() { + return NGX_CONF_ERROR; + } + + let cf = unsafe { &mut *cf }; + let amcf = HttpAcmeModule::main_conf_mut(cf).expect("acme main conf"); + + if acme::solvers::tls_alpn::merge_srv_conf(cf, amcf).is_err() { + return NGX_CONF_ERROR; + } + + NGX_CONF_OK + } + unsafe extern "C" fn postconfiguration(cf: *mut ngx_conf_t) -> ngx_int_t { let cf = unsafe { &mut *cf }; let amcf = HttpAcmeModule::main_conf_mut(cf).expect("acme main conf"); @@ -115,6 +142,12 @@ impl HttpModule for HttpAcmeModule { return err.into(); }; + /* tls-alpn-01 challenge handler */ + + if let Err(err) = acme::solvers::tls_alpn::postconfiguration(cf, amcf) { + return err.into(); + } + Status::NGX_OK.into() } } @@ -231,8 +264,17 @@ async fn ngx_http_acme_update_certificates_for_issuer( let amsh = amcf.data.expect("acme shared data"); - let http_solver = acme::solvers::http::Http01Solver::new(&amsh.http_01_state); - client.add_solver(http_solver); + match issuer.challenge { + Some(acme::types::ChallengeKind::Http01) => { + let http_solver = acme::solvers::http::Http01Solver::new(&amsh.http_01_state); + client.add_solver(http_solver); + } + Some(acme::types::ChallengeKind::TlsAlpn01) => { + let tls_solver = acme::solvers::tls_alpn::TlsAlpn01Solver::new(&amsh.tls_alpn_01_state); + client.add_solver(tls_solver); + } + _ => unreachable!("invalid configuration"), + }; let mut next = Time::MAX; diff --git a/src/state.rs b/src/state.rs index 88c3f44..8bdb820 100644 --- a/src/state.rs +++ b/src/state.rs @@ -32,6 +32,7 @@ where { pub issuers: Queue, A>, pub http_01_state: RwLock>, + pub tls_alpn_01_state: RwLock>, } impl AcmeSharedData @@ -40,9 +41,13 @@ where { pub fn try_new_in(alloc: A) -> Result { let http_01_state = acme::solvers::http::Http01SolverState::try_new_in(alloc.clone())?; + let tls_alpn_01_state = + acme::solvers::tls_alpn::TlsAlpn01SolverState::try_new_in(alloc.clone())?; + Ok(Self { issuers: Queue::try_new_in(alloc)?, http_01_state: RwLock::new(http_01_state), + tls_alpn_01_state: RwLock::new(tls_alpn_01_state), }) } } diff --git a/t/acme_tls_alpn.t b/t/acme_tls_alpn.t new file mode 100644 index 0000000..4facb5a --- /dev/null +++ b/t/acme_tls_alpn.t @@ -0,0 +1,134 @@ +#!/usr/bin/perl + +# Copyright (c) F5, Inc. +# +# This source code is licensed under the Apache License, Version 2.0 license +# found in the LICENSE file in the root directory of this source tree. + +# Tests for ACME client: TLS-ALPN-01 challenge. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::ACME; +use Test::Nginx::DNS; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http http_ssl sni socket_ssl/) + ->has_daemon('openssl'); + +$t->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + resolver 127.0.0.1:%%PORT_8980_UDP%%; + + acme_issuer default { + uri https://acme.test:%%PORT_9000%%/dir; + challenge tls-alpn-01; + ssl_trusted_certificate acme.test.crt; + state_path %%TESTDIR%%; + accept_terms_of_service; + } + + server { + listen 127.0.0.1:8443 ssl; + server_name .example.test; + + acme_certificate default; + + ssl_certificate $acme_certificate; + ssl_certificate_key $acme_certificate_key; + } +} + +EOF + +$t->write_file('openssl.conf', <testdir(); + +foreach my $name ('acme.test') { + system('openssl req -x509 -new ' + . "-config $d/openssl.conf -subj /CN=$name/ " + . "-out $d/$name.crt -keyout $d/$name.key " + . ">>$d/openssl.out 2>&1") == 0 + or die "Can't create certificate for $name: $!\n"; +} + +my $dp = port(8980, udp=>1); +my @dc = ( + { name => 'acme.test', A => '127.0.0.1' }, + { match => qr/^(\w+\.)?example.test$/, A => '127.0.0.1' } +); + +my $acme = Test::Nginx::ACME->new($t, port(9000), port(9001), + $t->testdir . '/acme.test.crt', + $t->testdir . '/acme.test.key', + tls_port => port(8443), + dns_port => $dp, + nosleep => 1, +); + +$t->run_daemon(\&Test::Nginx::DNS::dns_test_daemon, $t, $dp, \@dc); +$t->waitforfile($t->testdir . '/' . $dp); + +$t->run_daemon(\&Test::Nginx::ACME::acme_test_daemon, $t, $acme); +$t->waitforsocket('127.0.0.1:' . $acme->port()); +$t->write_file('acme-root.crt', $acme->trusted_ca()); + +$t->write_file('index.html', 'SUCCESS'); +$t->plan(1)->run(); + +############################################################################### + +$acme->wait_certificate('example.test') or die "no certificate"; + +like(get(8443, 'example.test', 'acme-root'), qr/SUCCESS/, 'tls request'); + +############################################################################### + +sub get { + my ($port, $host, $ca) = @_; + + $ca = undef if $IO::Socket::SSL::VERSION < 2.062 + || !eval { Net::SSLeay::X509_V_FLAG_PARTIAL_CHAIN() }; + + http_get('/', + PeerAddr => '127.0.0.1:' . port($port), + SSL => 1, + $ca ? ( + SSL_ca_file => "$d/$ca.crt", + SSL_verifycn_name => $host, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_PEER(), + ) : () + ); +} + +###############################################################################