diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b64432c..3564207 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -60,6 +60,13 @@ jobs: nginx-ref: stable-1.28 build: debug + env: + testTarget: >- + ${{ (matrix.nginx-ref == 'stable-1.28' && matrix.build == 'debug') + && 'full-test' + || 'test' + }} + runs-on: ${{ matrix.runner }}-latest steps: @@ -73,8 +80,6 @@ jobs: with: repository: 'nginx/nginx-tests' path: 'nginx/tests' - sparse-checkout: | - lib - uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b with: @@ -117,4 +122,4 @@ jobs: - name: run tests # always run if build succeeds if: ${{ !cancelled() && steps.build.outcome == 'success' }} - run: make BUILD=${{ matrix.build }} TEST_PREREQ= test + run: make BUILD=${{ matrix.build }} TEST_PREREQ= ${{ env.testTarget }} diff --git a/Makefile b/Makefile index e34dd1c..74dd2ce 100644 --- a/Makefile +++ b/Makefile @@ -99,7 +99,11 @@ unittest: $(NGINX_BUILD_DIR)/Makefile ## Run unit-tests $(BUILD_ENV) $(NGX_CARGO) test test: $(TEST_PREREQ) ## Run the integration test suite - env $(TEST_ENV) prove -I $(NGINX_TESTS_DIR)/lib --state=save $(TESTS) ||\ + env $(TEST_ENV) prove -I $(NGINX_TESTS_DIR)/lib -v $(TESTS) + +full-test: $(TEST_PREREQ) ## Run the module and NGINX integration test suites + env $(TEST_ENV) prove -I $(NGINX_TESTS_DIR)/lib --state=save \ + -j $(TEST_JOBS) $(TESTS) $(NGINX_TESTS_DIR) ||\ env $(TEST_ENV) prove -I $(NGINX_TESTS_DIR)/lib --state=failed -v clean: ## Cleanup everything diff --git a/README.md b/README.md index 2156165..d5a405a 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,14 @@ certificate management (ACMEv2) protocol. The module implements following specifications: - * [RFC8555] (Automatic Certificate Management Environment) with limitations: - * Only HTTP-01 challenge type is supported +- [RFC8555] (Automatic Certificate Management Environment) with limitations: + - Only HTTP-01 challenge type is 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 @@ -165,6 +168,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/build/compat-gnu.mk b/build/compat-gnu.mk index 2364e0f..bdf27ab 100644 --- a/build/compat-gnu.mk +++ b/build/compat-gnu.mk @@ -1,4 +1,5 @@ HOST_TUPLE := $(shell $(NGX_CARGO) -vV | awk '/^host: / { print $$2; }') +TEST_JOBS := $(shell nproc 2>/dev/null || getconf NPROCESSORS_ONLN 2>/dev/null || echo 1) # extension for Rust cdylib targets ifeq ($(shell uname), Darwin) diff --git a/build/compat-posix.mk b/build/compat-posix.mk index f3af3a6..4fec031 100644 --- a/build/compat-posix.mk +++ b/build/compat-posix.mk @@ -1,4 +1,5 @@ HOST_TUPLE != $(NGX_CARGO) -vV | awk '/^host: / { print $$2; }' +TEST_JOBS != nproc 2>/dev/null || getconf NPROCESSORS_ONLN 2>/dev/null || echo 1 # bsd make compatibility CURDIR ?= $(.CURDIR) diff --git a/src/acme.rs b/src/acme.rs index f6a69bb..8a51aa6 100644 --- a/src/acme.rs +++ b/src/acme.rs @@ -40,7 +40,13 @@ pub struct NewCertificateOutput { } pub struct AuthorizationContext<'a> { + /// Account key thumbprint. pub thumbprint: &'a [u8], + /// A private key generated for the new certificate request. + /// + /// This is used in tls-alpn-01 challenge to avoid generating a new key on each verification + /// attempt. + pub pkey: &'a PKeyRef, } pub struct AcmeClient<'a, Http> @@ -351,6 +357,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..1453664 --- /dev/null +++ b/src/acme/solvers/tls_alpn.rs @@ -0,0 +1,514 @@ +// 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; + +use nginx_sys::{ngx_conf_t, ngx_http_validate_host, ngx_str_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; +#[cfg(not(openssl = "openssl"))] +use openssl_sys::SSL_CTX_set_alpn_select_cb; +#[cfg(openssl = "openssl")] +use openssl_sys::SSL_CTX_set_alpn_select_cb__fixed_rust as SSL_CTX_set_alpn_select_cb; +use openssl_sys::{ + SSL_CTX_set_cert_cb, SSL_get_ex_data, SSL, SSL_CTX, 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 let Some(ssl_ctx) = unsafe { sscf.ssl.ctx.cast::().as_mut() } { + acme_register_client_hello_cb(ssl_ctx, amcf); + } + + 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 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)?; + 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) + } +} + +#[cfg(openssl = "openssl")] +fn acme_register_client_hello_cb(ssl_ctx: &mut SSL_CTX, amcf: &mut AcmeMainConfig) { + use openssl_sys::{SSL_CLIENT_HELLO_ERROR, SSL_CLIENT_HELLO_SUCCESS}; + + fn ssl_client_hello_get_ext(ssl: &mut SSL, typ: c_uint) -> Option { + 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, typ, &mut p, &mut len) }; + match rc { + 1 => Some(ngx_str_t { + data: p.cast_mut(), + len, + }), + _ => None, + } + } + + extern "C" fn ssl_client_hello_cb( + ssl: *mut SSL, + _alert: *mut c_int, + data: *mut c_void, + ) -> c_int { + let ssl = unsafe { ssl.as_mut() }.expect("valid SSL ptr passed to callback"); + + let c: *mut nginx_sys::ngx_connection_t = + unsafe { SSL_get_ex_data(ssl, nginx_sys::ngx_ssl_connection_index).cast() }; + + let Some(amcf) = (unsafe { data.cast::().as_mut() }) else { + return SSL_CLIENT_HELLO_ERROR; + }; + + let Some(alpn) = + ssl_client_hello_get_ext(ssl, TLSEXT_TYPE_application_layer_protocol_negotiation) + else { + return SSL_CLIENT_HELLO_SUCCESS; + }; + + if alpn.is_empty() { + return SSL_CLIENT_HELLO_ERROR; + } + + if let Err(err) = acme_client_hello_handler(ssl, amcf, alpn.as_bytes()) { + ngx_log_error!( + nginx_sys::NGX_LOG_WARN, + unsafe { (*c).log }, + "acme/tls-alpn-01: {}", + err + ); + return SSL_CLIENT_HELLO_ERROR; + } + + SSL_CLIENT_HELLO_SUCCESS + } + + unsafe { + openssl_sys::SSL_CTX_set_client_hello_cb( + ssl_ctx, + Some(ssl_client_hello_cb), + ptr::from_mut(amcf).cast(), + ) + }; +} + +#[cfg(any(openssl = "awslc", openssl = "boringssl"))] +fn acme_register_client_hello_cb(ssl_ctx: &mut SSL_CTX, _amcf: &mut AcmeMainConfig) { + use ngx::http::HttpModuleMainConf; + use openssl_sys::SSL_CLIENT_HELLO; + + fn ssl_client_hello_get_ext(client_hello: &SSL_CLIENT_HELLO, typ: c_uint) -> Option { + let mut p: *const u8 = ptr::null_mut(); + let mut len = 0usize; + + let rc = unsafe { + openssl_sys::SSL_early_callback_ctx_extension_get( + client_hello, + typ as _, + &mut p, + &mut len, + ) + }; + match rc { + 1 => Some(ngx_str_t { + data: p.cast_mut(), + len, + }), + _ => None, + } + } + + extern "C" fn ssl_select_certificate_cb( + client_hello: *const SSL_CLIENT_HELLO, + ) -> openssl_sys::ssl_select_cert_result_t { + let client_hello = unsafe { client_hello.as_ref() } + .expect("valid SSL_CLIENT_HELLO ptr passed to callback"); + let ssl = unsafe { client_hello.ssl.as_mut() }.expect("valid SSL ptr passed to callback"); + let c: *mut nginx_sys::ngx_connection_t = + unsafe { SSL_get_ex_data(ssl, nginx_sys::ngx_ssl_connection_index).cast() }; + + let Some(alpn) = ssl_client_hello_get_ext( + client_hello, + TLSEXT_TYPE_application_layer_protocol_negotiation, + ) else { + return openssl_sys::ssl_select_cert_result_t_ssl_select_cert_success; + }; + + if alpn.is_empty() { + return openssl_sys::ssl_select_cert_result_t_ssl_select_cert_error; + } + + let Some(amcf) = crate::HttpAcmeModule::main_conf(unsafe { &*nginx_sys::ngx_cycle }) else { + return openssl_sys::ssl_select_cert_result_t_ssl_select_cert_error; + }; + + if let Err(err) = acme_client_hello_handler(ssl, amcf, alpn.as_bytes()) { + ngx_log_error!( + nginx_sys::NGX_LOG_WARN, + unsafe { (*c).log }, + "acme/tls-alpn-01: {}", + err + ); + return openssl_sys::ssl_select_cert_result_t_ssl_select_cert_error; + } + + openssl_sys::ssl_select_cert_result_t_ssl_select_cert_success + } + + unsafe { + openssl_sys::SSL_CTX_set_select_certificate_cb(ssl_ctx, Some(ssl_select_certificate_cb)) + }; +} + +fn acme_client_hello_handler( + ssl: &mut SSL, + amcf: &AcmeMainConfig, + alpn: &[u8], +) -> Result<(), &'static str> { + 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, + }; + + let Some(mut iter) = TlsAlpnIter::new(alpn) else { + return Err("invalid alpn extension data"); + }; + + if !iter.any(|x| x == b"acme-tls/1") { + return Ok(()); + } + + let ssl_ctx = amcf.ssl.as_ref().ctx.cast::(); + if ssl_ctx.is_null() { + return Err("no ssl context"); + } + + if unsafe { SSL_set_SSL_CTX(ssl, ssl_ctx).is_null() } { + return Err("SSL_set_SSL_CTX() failed"); + } + + unsafe { + SSL_set_verify( + ssl, + SSL_CTX_get_verify_mode(ssl_ctx), + SSL_CTX_get_verify_callback(ssl_ctx), + ); + + SSL_clear_options(ssl, SSL_get_options(ssl) & !SSL_CTX_get_options(ssl_ctx)); + SSL_set_options(ssl, SSL_CTX_get_options(ssl_ctx)); + SSL_set_options(ssl, openssl::ssl::SslOptions::NO_RENEGOTIATION.bits()); + } + + Ok(()) +} + +unsafe extern "C" fn ssl_cert_cb(ssl: *mut 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() { + // not an error + return 0; + } + + let mut name = ngx_str_t { + data: name.cast_mut().cast(), + len: CStr::from_ptr(name).count_bytes(), + }; + + if !Status(ngx_http_validate_host(&mut name, c.as_ref().pool, 1)).is_ok() { + ngx_log_error!( + NGX_LOG_ERR, + log, + "acme/tls-alpn-01: invalid server name: {name}" + ); + return 0; + } + + 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 (auth, pkey) = if let Some(resp) = amsh.tls_alpn_01_state.read().get(name.as_bytes()) { + ( + openssl::sha::sha256(resp.key_authorization.as_ref()), + PKey::private_key_from_pem(resp.pkey.as_ref()), + ) + } else { + ngx_log_debug!(log, "acme/tls-alpn-01: no challenge registered for {name}",); + return 0; + }; + + // XXX: fallback to key generation + let pkey = match pkey { + Ok(pkey) => pkey, + Err(err) => { + ngx_log_error!(NGX_LOG_ERR, log, "acme/tls-alpn-01: handler failed: {err}"); + return 0; + } + }; + + ngx_log_debug!(log, "acme/tls-alpn-01: challenge for {name}"); + + let id = Identifier::Dns(name); + 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; + } + + // Ask ngx_http_ssl_handshake to terminate the connection without logging an error. + c.as_mut().set_close(1); + + 1 +} + +extern "C" fn ssl_alpn_select_cb( + _ssl: *mut 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"\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 _, + } +} + +const SHA256_DIGEST_LENGTH: usize = 0x20; + +pub fn make_challenge_cert( + identifier: &Identifier<&str>, + key_authorization: &[u8; SHA256_DIGEST_LENGTH], + 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; SHA256_DIGEST_LENGTH + 2]; + digest[0] = openssl_sys::V_ASN1_OCTET_STRING as _; + digest[1] = SHA256_DIGEST_LENGTH as _; + digest[2..].copy_from_slice(key_authorization); + let digest = openssl::asn1::Asn1OctetString::new_from_bytes(digest.as_slice())?; + + 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 edec702..f492094 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; @@ -39,6 +41,7 @@ const NGX_CONF_INVALID_VALUE: *mut c_char = c"invalid value".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, } @@ -80,7 +83,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; 9] = [ +static mut NGX_HTTP_ACME_ISSUER_COMMANDS: [ngx_command_t; 10] = [ ngx_command_t { name: ngx_string!("uri"), type_: NGX_CONF_TAKE1 as ngx_uint_t, @@ -97,6 +100,14 @@ static mut NGX_HTTP_ACME_ISSUER_COMMANDS: [ngx_command_t; 9] = [ 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, @@ -313,6 +324,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 4ed624f..74b3e6a 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<&'static str, Pool>, pub eab_key: Option, pub resolver: Option>, @@ -94,6 +96,7 @@ impl Issuer { name, uri: Default::default(), account_key: PrivateKey::Unset, + challenge: None, contacts: Vec::new_in(alloc.clone()), eab_key: None, resolver: None, @@ -132,6 +135,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(), + ) : () + ); +} + +###############################################################################