diff --git a/README.md b/README.md index cda8c41..6ad7afc 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,24 @@ In both cases, the key is expected to be encoded in [RFC8555#eab]: https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.4 +### preferred_chain + +**Syntax:** **`preferred_chain`** _`issuer name`_ + +**Default:** - + +**Context:** acme_issuer + +_This directive appeared in version 0.3.0._ + +Specifies the preferred certificate chain. + +If the ACME issuer offers multiple certificate chains, +prefer the chain with the topmost certificate issued from the +Subject Common Name _`issuer name`_. + +If no matches, the default chain will be used. + ### profile **Syntax:** **`profile`** _`name`_ \[`require`] @@ -298,6 +316,8 @@ In both cases, the key is expected to be encoded in **Context:** acme_issuer +_This directive appeared in version 0.3.0._ + Requests the supported [certificate profile][draft-ietf-acme-profiles] _`name`_ from the ACME server. diff --git a/src/acme.rs b/src/acme.rs index 78c7a55..c55517f 100644 --- a/src/acme.rs +++ b/src/acme.rs @@ -18,19 +18,20 @@ use ngx::async_::sleep; use ngx::collections::Vec; use ngx::ngx_log_debug; use openssl::pkey::{PKey, PKeyRef, Private}; -use openssl::x509::{self, extension as x509_ext, X509Req}; +use openssl::x509::{self, extension as x509_ext, X509Req, X509}; use types::{AccountStatus, ProblemCategory}; use self::account_key::{AccountKey, AccountKeyError}; use self::types::{AuthorizationStatus, ChallengeKind, ChallengeStatus, OrderStatus}; use crate::conf::identifier::Identifier; -use crate::conf::issuer::{Issuer, Profile}; +use crate::conf::issuer::{CertificateChainMatcher, Issuer, Profile}; use crate::conf::order::CertificateOrder; use crate::net::http::HttpClient; use crate::time::Time; pub mod account_key; pub mod error; +pub mod headers; pub mod solvers; pub mod types; @@ -53,7 +54,8 @@ pub enum NewAccountOutput<'a> { } pub struct NewCertificateOutput { - pub chain: Bytes, + pub bytes: Bytes, + pub x509: std::vec::Vec, pub pkey: PKey, } @@ -272,7 +274,7 @@ where if let Some(val) = res .headers() .get(http::header::RETRY_AFTER) - .and_then(parse_retry_after) + .and_then(headers::parse_retry_after) .filter(|x| x > &MAX_SERVER_RETRY_INTERVAL) { return Err(RequestError::RateLimited(val)); @@ -487,11 +489,69 @@ where let certificate = order .certificate - .ok_or(NewCertificateError::CertificateUrl)?; + .ok_or(NewCertificateError::MissingCertificate)?; - let chain = self.post(&certificate, b"").await?.into_body(); + let res = self.post(&certificate, b"").await?; - Ok(NewCertificateOutput { chain, pkey }) + if let Some(ref matcher) = self.issuer.chain { + let (bytes, x509) = self + .find_preferred_chain(&certificate, res, matcher) + .await?; + Ok(NewCertificateOutput { bytes, x509, pkey }) + } else { + let bytes = res.into_body(); + let x509 = + X509::stack_from_pem(&bytes).map_err(NewCertificateError::InvalidCertificate)?; + if x509.is_empty() { + return Err(NewCertificateError::MissingCertificate); + } + + Ok(NewCertificateOutput { bytes, x509, pkey }) + } + } + + async fn find_preferred_chain( + &self, + base: &Uri, + cert: http::Response, + matcher: &CertificateChainMatcher, + ) -> Result<(Bytes, std::vec::Vec), NewCertificateError> { + let default = + X509::stack_from_pem(cert.body()).map_err(NewCertificateError::InvalidCertificate)?; + + if !matcher.test(&default) { + if let Ok(base) = iri_string::types::UriAbsoluteString::try_from(base.to_string()) { + let alternates = cert + .headers() + .get_all(http::header::LINK) + .into_iter() + .filter_map(headers::parse_link) + .flatten() + .filter(|x| x.is_rel("alternate")); + + for link in alternates { + let uri = link.target.resolve_against(&base).to_string(); + let Ok(uri) = Uri::try_from(uri) else { + continue; + }; + + let res = self.post(&uri, b"").await?; + let bytes = res.into_body(); + + let stack = X509::stack_from_pem(&bytes) + .map_err(NewCertificateError::InvalidCertificate)?; + if matcher.test(&stack) { + return Ok((bytes, stack)); + } + } + } + } + + if default.is_empty() { + return Err(NewCertificateError::MissingCertificate); + } + + Ok((cert.into_body(), default)) } async fn do_authorization( @@ -612,7 +672,7 @@ async fn wait_for_retry( let retry_after = res .headers() .get(http::header::RETRY_AFTER) - .and_then(parse_retry_after) + .and_then(headers::parse_retry_after) .unwrap_or(interval) .min(MAX_SERVER_RETRY_INTERVAL); @@ -642,15 +702,3 @@ where { serde_json::from_slice(bytes).map_err(RequestError::ResponseFormat) } - -fn parse_retry_after(val: &http::HeaderValue) -> Option { - let val = val.to_str().ok()?; - - // Retry-After: - if let Ok(time) = Time::parse(val) { - return Some(time - Time::now()); - } - - // Retry-After: - val.parse().map(Duration::from_secs).ok() -} diff --git a/src/acme/error.rs b/src/acme/error.rs index ee7def2..2138e35 100644 --- a/src/acme/error.rs +++ b/src/acme/error.rs @@ -62,15 +62,18 @@ pub enum NewCertificateError { #[error("unexpected authorization status {0:?}")] AuthorizationStatus(super::types::AuthorizationStatus), - #[error("no certificate in the validated order")] - CertificateUrl, - #[error("unexpected challenge status {0:?}")] ChallengeStatus(super::types::ChallengeStatus), #[error("csr generation failed ({0})")] Csr(openssl::error::ErrorStack), + #[error("PEM_read_bio_X509() failed: {0}")] + InvalidCertificate(openssl::error::ErrorStack), + + #[error("no certificate in the completed order")] + MissingCertificate, + #[error("no supported challenges")] NoSupportedChallenges, diff --git a/src/acme/headers.rs b/src/acme/headers.rs new file mode 100644 index 0000000..772a92d --- /dev/null +++ b/src/acme/headers.rs @@ -0,0 +1,278 @@ +// 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::time::Duration; + +use iri_string::types::UriReferenceStr; +use ngx::collections::Vec; + +use crate::time::Time; + +/// Represents an RFC8288 Link header value. +/// +/// The `Link` header is used to indicate relationships between resources, +/// as defined in [RFC8288](https://datatracker.ietf.org/doc/html/rfc8288). +pub struct Link<'a> { + /// The URI reference target of the link. + pub target: &'a UriReferenceStr, + /// The relationship types (the `rel` parameter) associated with the link. + pub rel: Vec<&'a str>, +} + +impl Link<'_> { + pub fn is_rel(&self, rel: &str) -> bool { + self.rel.iter().any(|x| x.eq_ignore_ascii_case(rel)) + } +} + +/// An iterator over Link header values parsed from an RFC8288-compliant Link header string. +pub struct LinkIter<'a>(&'a str); + +impl<'a> LinkIter<'a> { + pub fn new(val: &'a http::header::HeaderValue) -> Result { + val.to_str().map(Self) + } +} + +impl<'a> Iterator for LinkIter<'a> { + type Item = Link<'a>; + + fn next(&mut self) -> Option { + // Link = [ ( "," / link-value ) *( OWS "," [ OWS link-value ] ) ] + + // link-value = "<" URI-Reference ">" *( OWS ";" OWS link-param ) + fn consume_link_value(p: &str) -> Option<(Link<'_>, &str)> { + let p = p.trim_ascii_start().strip_prefix('<')?; + + let (target, mut p) = p.split_once('>')?; + if target.is_empty() { + return None; + } + let target = UriReferenceStr::new(target).ok()?; + + let mut rel = Vec::new(); + + loop { + p = p.trim_ascii_start(); + + if p.is_empty() { + break; + } + + if let Some(rest) = p.strip_prefix(',') { + p = rest; + break; + } + + p = p.strip_prefix(';')?; + + let name; + let value; + (name, value, p) = consume_link_param(p)?; + + // 9. Let relations_string be the second item of the first tuple + // of link_parameters whose first item matches the string "rel" + // or the empty string ("") if it is not present. + // 10. Split relations_string on RWS (removing it in the process) + // into a list of string relation_types. + + if rel.is_empty() && name.eq_ignore_ascii_case("rel") { + rel.extend(value.split_ascii_whitespace()); + } + } + + Some((Link { target, rel }, p)) + } + + // link-param = token BWS [ "=" BWS ( token / quoted-string ) ] + fn consume_link_param(p: &str) -> Option<(&str, &str, &str)> { + let p = p.trim_ascii_start(); + + let Some(end) = p.find(['=', ';', ',']) else { + return Some((p, "", "")); + }; + + let (name, p) = p.split_at(end); + + let (value, p) = if let Some(mut p) = p.strip_prefix('=') { + p = p.trim_ascii_start(); + + if let Some(p) = p.strip_prefix('"') { + consume_quoted_string(p)? + } else if let Some(end) = p.find([';', ',']) { + let (value, p) = p.split_at(end); + (value.trim_ascii_end(), p) + } else { + (p.trim_ascii_end(), "") + } + } else { + ("", p) + }; + + Some((name.trim_ascii_end(), value, p)) + } + + fn consume_quoted_string(p: &str) -> Option<(&str, &str)> { + let mut escape = false; + + for (i, c) in p.char_indices() { + if c == '\\' { + escape = !escape; + } else if c == '"' && !escape { + let (head, tail) = p.split_at(i); + return Some((head, tail.strip_prefix('"')?)); + } else { + escape = false; + } + } + + Some((p, "")) + } + + let link; + + self.0 = self.0.trim_ascii_start(); + while let Some(p) = self.0.strip_prefix(',') { + self.0 = p.trim_ascii_start(); + } + + (link, self.0) = consume_link_value(self.0)?; + + Some(link) + } +} + +pub fn parse_link(val: &http::HeaderValue) -> Option> { + LinkIter::new(val).ok() +} + +pub fn parse_retry_after(val: &http::HeaderValue) -> Option { + let val = val.to_str().ok()?; + + // Retry-After: + if let Ok(time) = Time::parse(val) { + return Some(time - Time::now()); + } + + // Retry-After: + val.parse().map(Duration::from_secs).ok() +} + +#[cfg(test)] +mod tests { + + #[test] + fn parse_link() { + type Expected<'a> = (&'a str, &'a [&'a str]); + + const LINKS: &[(&str, &[Expected])] = &[ + ( + // index + ";rel=\"index\"", + &[("https://example.com/acme/directory", &["index"])], + ), + ( + // alternate quoted + "; rel=\"alternate\"", + &[( + "https://example.com/acme/cert/mAt3xBGaobw/1", + &["alternate"], + )], + ), + ( + // alternate unquoted + "; rel=alternate", + &[( + "https://example.com/acme/cert/mAt3xBGaobw/1", + &["alternate"], + )], + ), + ( + // no rel + ";", + &[("https://example.com/acme/directory", &[])], + ), + ( + // rel splitting + "; rel=\"alternate index\"", + &[("test", &["alternate", "index"])], + ), + ( + // multiple parameters + "; foo=bar; foobar; rel=\"alternate\"; rel=\"index\"", + &[("test", &["alternate"])], + ), + ( + // spaces, commas and other parser sanity checks + concat!( + " , ;\t foo=\";,=<>\"; rel = \"index\"\t, ,,, ", + " , ;\t foo=\";,=<>\"; rel = \"index\"\t, ,,, " + ), + &[ + ("https://example.com/acme/directory", &["index"]), + ("https://example.com/acme/directory", &["index"]) + ], + ), + ( + ";rel=\"index", + &[("https://example.com/acme/directory", &["index"])], + ), + ( + r#";rel="index\""#, + &[("https://example.com/acme/directory", &["index\\\""])], + ), + ( + // multiple link-values + concat!( + ";rel=\"index\",", + ";rel=alternate,", + ";rel=alternate" + ), + &[ + ("https://example.com/acme/directory", &["index"]), + ( + "https://example.com/acme/cert/mAt3xBGaobw/1", + &["alternate"], + ), + ( + "https://example.com/acme/cert/mAt3xBGaobw/2", + &["alternate"], + ), + ], + ), + ( + // title encoding + concat!( + ";", + "rel=\"previous\"; title*=UTF-8'de'letztes%20Kapitel,", + ";", + "title*=UTF-8'de'n%c3%a4chstes%20Kapitel; rel=\"next\"", + ), + &[ + ("/TheBook/chapter2", &["previous"]), + ("/TheBook/chapter4", &["next"]), + ], + ), + // bad values + ("; rel=alternate", &[]), + ("<>, rel=alternate", &[]), + ]; + + for (val, expected) in LINKS { + let val = http::HeaderValue::from_static(val); + let mut links = super::parse_link(&val).expect("valid header encoding"); + + for (uri, rel) in *expected { + let link = links.next().expect("link-value"); + assert_eq!(link.target, *uri); + assert_eq!(&link.rel, rel); + } + + assert!(links.next().is_none()); + } + } +} diff --git a/src/conf.rs b/src/conf.rs index cbaa64d..1e96cc3 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -84,7 +84,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; 11] = [ +static mut NGX_HTTP_ACME_ISSUER_COMMANDS: [ngx_command_t; 12] = [ ngx_command_t { name: ngx_string!("uri"), type_: NGX_CONF_TAKE1 as ngx_uint_t, @@ -125,6 +125,14 @@ static mut NGX_HTTP_ACME_ISSUER_COMMANDS: [ngx_command_t; 11] = [ offset: 0, post: ptr::null_mut(), }, + ngx_command_t { + name: ngx_string!("preferred_chain"), + type_: NGX_CONF_TAKE1 as ngx_uint_t, + set: Some(cmd_issuer_set_preferred_chain), + conf: 0, + offset: 0, + post: ptr::null_mut(), + }, ngx_command_t { name: ngx_string!("profile"), type_: nginx_sys::NGX_CONF_TAKE12 as ngx_uint_t, @@ -514,6 +522,32 @@ extern "C" fn cmd_issuer_set_external_account_key( NGX_CONF_OK } +extern "C" fn cmd_issuer_set_preferred_chain( + 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.chain.is_some() { + return NGX_CONF_DUPLICATE; + } + + // NGX_CONF_TAKE1 ensures that args contains 2 elements + let args = cf.args(); + + // SAFETY: the value is well aligned, and the conversion result is assigned to an object in + // the same pool. + let Ok(issuer_name) = (unsafe { conf_value_to_str(&args[1]) }) else { + return NGX_CONF_INVALID_VALUE; + }; + + issuer.chain = Some(issuer::CertificateChainMatcher::new(issuer_name)); + + NGX_CONF_OK +} + extern "C" fn cmd_issuer_set_profile( cf: *mut ngx_conf_t, _cmd: *mut ngx_command_t, diff --git a/src/conf/issuer.rs b/src/conf/issuer.rs index 5d69444..7fa6da4 100644 --- a/src/conf/issuer.rs +++ b/src/conf/issuer.rs @@ -47,6 +47,7 @@ pub struct Issuer { pub name: ngx_str_t, pub uri: Uri, pub account_key: PrivateKey, + pub chain: Option, pub challenge: Option, pub contacts: Vec<&'static str, Pool>, pub eab_key: Option, @@ -65,6 +66,9 @@ pub struct Issuer { pub data: Option<&'static RwLock>, } +#[derive(Debug)] +pub struct CertificateChainMatcher(&'static str); + #[derive(Debug)] pub struct ExternalAccountKey { pub kid: &'static str, @@ -107,6 +111,7 @@ impl Issuer { name, uri: Default::default(), account_key: PrivateKey::Unset, + chain: None, challenge: None, contacts: Vec::new_in(alloc.clone()), eab_key: None, @@ -350,6 +355,24 @@ impl Issuer { } } +impl CertificateChainMatcher { + pub fn new(issuer_name: &'static str) -> Self { + Self(issuer_name) + } + + pub fn test>(&self, chain: &[T]) -> bool { + if let Some(intermediate) = chain.last() { + intermediate + .as_ref() + .issuer_name() + .entries_by_nid(openssl::nid::Nid::COMMONNAME) + .any(|x| x.data().as_slice() == self.0.as_bytes()) + } else { + false + } + } +} + fn default_state_path(cf: &mut ngx_conf_t, name: &ngx_str_t) -> Result { let mut path = Vec::new_in(cf.pool()); let reserve = "acme_".len() + name.len + 1; diff --git a/src/lib.rs b/src/lib.rs index ace6779..beb1794 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,7 +18,6 @@ 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; use time::TimeRange; use zeroize::Zeroizing; @@ -336,12 +335,11 @@ async fn ngx_http_acme_update_certificates_for_issuer( let cert_next = match client.new_certificate(order).await { Ok(ref val) => { let pkey = Zeroizing::new(val.pkey.private_key_to_pem_pkcs8()?); - let x509 = X509::from_pem(&val.chain)?; let now = Time::now(); - let valid = TimeRange::from_x509(&x509).unwrap_or(TimeRange::new(now, now)); + let valid = TimeRange::from_x509(&val.x509[0]).unwrap_or(TimeRange::new(now, now)); - let res = cert.write().set(&val.chain, &pkey, valid); + let res = cert.write().set(&val.bytes, &pkey, valid); let next = match res { Ok(x) => { @@ -368,7 +366,7 @@ async fn ngx_http_acme_update_certificates_for_issuer( // Write files even if we failed to update the shared zone. - let _ = issuer.write_state_file(std::format!("{order_id}.crt"), &val.chain); + let _ = issuer.write_state_file(std::format!("{order_id}.crt"), &val.bytes); if !matches!(order.key, conf::pkey::PrivateKey::File(_)) { let _ = issuer.write_state_file(std::format!("{order_id}.key"), &pkey); diff --git a/t/acme_preferred_chain.t b/t/acme_preferred_chain.t new file mode 100644 index 0000000..14d0b70 --- /dev/null +++ b/t/acme_preferred_chain.t @@ -0,0 +1,211 @@ +#!/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: preferred chain support. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +use Net::SSLeay qw/ die_now /; + +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 socket_ssl/) + ->has_daemon('openssl'); + +my $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; + ssl_trusted_certificate acme.test.crt; + state_path %%TESTDIR%%/acme_default; + accept_terms_of_service; + } + + acme_issuer chain1 { + uri https://acme.test:%%PORT_9000%%/dir; + preferred_chain "%%ISSUER_NAME_1%%"; + ssl_trusted_certificate acme.test.crt; + state_path %%TESTDIR%%/acme_chain1; + accept_terms_of_service; + } + + acme_issuer chain2 { + uri https://acme.test:%%PORT_9000%%/dir; + preferred_chain "%%ISSUER_NAME_2%%"; + ssl_trusted_certificate acme.test.crt; + state_path %%TESTDIR%%/acme_chain2; + accept_terms_of_service; + } + + ssl_certificate $acme_certificate; + ssl_certificate_key $acme_certificate_key; + + server { + listen 127.0.0.1:8443 ssl; + server_name example.test; + + acme_certificate default; + } + + server { + listen 127.0.0.1:8444 ssl; + server_name example.test; + + acme_certificate chain1; + } + + server { + listen 127.0.0.1:8445 ssl; + server_name example.test; + + acme_certificate chain2; + } + + server { + listen 127.0.0.1:8080; + server_name example.test; + } +} + +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' }, + { name => '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', + alternate_roots => 2, + http_port => port(8080), + 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-0.crt', $acme->trusted_ca(0)); +$t->write_file('acme-root-1.crt', $acme->trusted_ca(1)); +$t->write_file('acme-root-2.crt', $acme->trusted_ca(2)); + +# Pebble Root name is randomly generated + +my $cn = cert_name($t->testdir . '/acme-root-1.crt') + or die "Can't get CA certificate name: $!"; +$conf =~ s/%%ISSUER_NAME_1%%/$cn/; + +$cn = cert_name($t->testdir . '/acme-root-2.crt') + or die "Can't get CA certificate name: $!"; +$conf =~ s/%%ISSUER_NAME_2%%/$cn/; + +$t->write_file_expand('nginx.conf', $conf); + +$t->write_file('index.html', 'SUCCESS'); +$t->plan(5)->run(); + +############################################################################### + +$acme->wait_certificate('acme_default/example.test') or die "no certificate"; +$acme->wait_certificate('acme_chain1/example.test') or die "no certificate"; +$acme->wait_certificate('acme_chain2/example.test') or die "no certificate"; + +like(get(8443, 'example.test', 'acme-root-0'), qr/SUCCESS/, 'default'); + +like(get(8444, 'example.test', 'acme-root-1'), qr/SUCCESS/, 'chain 1'); +is(get(8444, 'example.test', 'acme-root-0'), undef, 'chain 1 - wrong root'); + +like(get(8445, 'example.test', 'acme-root-2'), qr/SUCCESS/, 'chain 2'); +is(get(8445, 'example.test', 'acme-root-0'), undef, 'chain 2 - wrong root'); + +############################################################################### + +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(), + ) : () + ); +} + +sub cert_name { + my ($filename) = @_; + + my $bio = Net::SSLeay::BIO_new_file($filename, 'r') + or die_now("BIO_new_file() failed: $!"); + + my $cert = Net::SSLeay::PEM_read_bio_X509($bio) + or die_now("PEM_read_bio_X509() failed: $!"); + + my $name = Net::SSLeay::X509_get_subject_name($cert) + or die_now("X509_get_subject_name() failed: $!"); + + return Net::SSLeay::X509_NAME_get_text_by_NID( + $name, + Net::SSLeay::NID_commonName() + ); +} + +############################################################################### diff --git a/t/lib/Test/Nginx/ACME.pm b/t/lib/Test/Nginx/ACME.pm index 54b5ddb..6fedbe9 100644 --- a/t/lib/Test/Nginx/ACME.pm +++ b/t/lib/Test/Nginx/ACME.pm @@ -43,6 +43,7 @@ sub new { my $tls_port = $extra{tls_port} || 443; my $validity = $extra{validity} || 3600; + $self->{alternate_roots} = $extra{alternate_roots}; $self->{dns_port} = $extra{dns_port} || Test::Nginx::port(8980, udp=>1); $self->{noncereject} = $extra{noncereject}; $self->{nosleep} = $extra{nosleep}; @@ -84,10 +85,9 @@ sub port { } sub trusted_ca { - my $self = shift; + my ($self, $chain) = @_; Test::Nginx::log_core('|| ACME: get certificate from', $self->{mgmt}); - my $cert = _get_body($self->{mgmt}, '/roots/0'); - $cert =~ s/(BEGIN|END) C/$1 TRUSTED C/g; + my $cert = _get_body($self->{mgmt}, '/roots/' . ($chain // 0)); $cert; } @@ -100,7 +100,7 @@ sub wait_certificate { my $timeout = ($extra{'timeout'} // 20) * 5; for (1 .. $timeout) { - return 1 if defined glob($file); + return 1 if scalar @{[ glob $file ]}; select undef, undef, undef, 0.2; } } @@ -176,6 +176,8 @@ sub acme_test_daemon { my $port = $acme->{port}; my $dnsserver = '127.0.0.1:' . $acme->{dns_port}; + $ENV{PEBBLE_ALTERNATE_ROOTS} = + $acme->{alternate_roots} if $acme->{alternate_roots}; $ENV{PEBBLE_VA_NOSLEEP} = 1 if $acme->{nosleep}; $ENV{PEBBLE_WFE_NONCEREJECT} = $acme->{noncereject} if $acme->{noncereject};