diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 395eef2..41da8fe 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -126,7 +126,9 @@ jobs: - uses: perl-actions/install-with-cpm@8b1a9840b26cc3885ae2889749a48629be2501b0 # v1.9 with: - install: IO::Socket::SSL + install: | + IO::Socket::SSL + TimeDate - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: diff --git a/.github/workflows/sanitizers.yaml b/.github/workflows/sanitizers.yaml index 1fc0aad..b9be5b9 100644 --- a/.github/workflows/sanitizers.yaml +++ b/.github/workflows/sanitizers.yaml @@ -21,6 +21,7 @@ env: perl-IO-Socket-SSL perl-Test-Harness perl-Test-Simple + perl-TimeDate perl-lib jobs: diff --git a/README.md b/README.md index 354b227..15f8dee 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,12 @@ The module implements following specifications: - Only HTTP-01 challenge type is supported - [RFC8737] (ACME TLS Application-Layer Protocol Negotiation (ALPN) Challenge Extension) +- [draft-ietf-acme-profiles] (ACME Profiles Extension, version 00) [NGINX]: https://nginx.org/ [RFC8555]: https://datatracker.ietf.org/doc/html/rfc8555 [RFC8737]: https://datatracker.ietf.org/doc/html/rfc8737 +[draft-ietf-acme-profiles]: https://datatracker.ietf.org/doc/draft-ietf-acme-profiles/ ## Getting Started @@ -283,6 +285,21 @@ In both cases, the key is expected to be encoded in [RFC8555#eab]: https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.4 +### profile + +**Syntax:** **`profile`** _`name`_ \[`require`] + +**Default:** - + +**Context:** acme_issuer + +Requests the supported [certificate profile][draft-ietf-acme-profiles] +_`name`_ from the ACME server. + +The `require` parameter will cause the account registration and certificate +renewals to fail if the ACME server does not advertise support for the +specified profile. + ### ssl_trusted_certificate **Syntax:** **`ssl_trusted_certificate`** _`file`_ diff --git a/src/acme.rs b/src/acme.rs index 6876326..9da591f 100644 --- a/src/acme.rs +++ b/src/acme.rs @@ -23,7 +23,7 @@ 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; +use crate::conf::issuer::{Issuer, Profile}; use crate::conf::order::CertificateOrder; use crate::net::http::HttpClient; use crate::time::Time; @@ -72,6 +72,7 @@ where log: NonNull, key: AccountKey, account: Option, + profile: Option<&'a str>, nonce: NoncePool, directory: types::Directory, solvers: Vec>, @@ -131,6 +132,7 @@ where log, key, account: None, + profile: None, nonce: Default::default(), directory: Default::default(), solvers: Vec::new(), @@ -299,6 +301,20 @@ where }) .transpose()?; + self.profile = match self.issuer.profile { + Profile::Required(x) => Some(x), + Profile::Preferred(x) if self.directory.meta.profiles.contains_key(x) => Some(x), + Profile::Preferred(x) => { + ngx::ngx_log_error!( + nginx_sys::NGX_LOG_NOTICE, + self.log.as_ptr(), + "acme profile \"{x}\" is not supported by the server" + ); + None + } + _ => None, + }; + let payload = types::AccountRequest { terms_of_service_agreed: self.issuer.accept_tos, contact: &self.issuer.contacts, @@ -350,6 +366,7 @@ where identifiers: &identifiers, not_before: None, not_after: None, + profile: self.profile, }; let payload = serde_json::to_string(&payload).map_err(RequestError::RequestFormat)?; diff --git a/src/acme/types.rs b/src/acme/types.rs index 7bc5efe..5aa1504 100644 --- a/src/acme/types.rs +++ b/src/acme/types.rs @@ -10,7 +10,7 @@ use std::string::{String, ToString}; use http::Uri; use ngx::collections::Vec; -use serde::{Deserialize, Serialize}; +use serde::{de::IgnoredAny, Deserialize, Serialize}; use crate::conf::identifier::Identifier; @@ -22,6 +22,8 @@ pub struct DirectoryMetadata { pub website: Option, pub caa_identities: Option>, pub external_account_required: Option, + #[serde(deserialize_with = "deserialize_null_as_default")] + pub profiles: std::collections::BTreeMap, } /// RFC8555 Section 7.1.1 Directory @@ -118,6 +120,8 @@ pub struct OrderRequest<'a> { pub not_before: Option, #[serde(skip_serializing_if = "Option::is_none")] pub not_after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option<&'a str>, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] @@ -200,6 +204,7 @@ pub enum ErrorKind { ExternalAccountRequired, IncorrectResponse, InvalidContact, + InvalidProfile, Malformed, OrderNotReady, RateLimited, @@ -232,6 +237,7 @@ const ERROR_KIND: &[(&str, ErrorKind)] = &[ ), ("incorrectResponse", ErrorKind::IncorrectResponse), ("invalidContact", ErrorKind::InvalidContact), + ("invalidProfile", ErrorKind::InvalidProfile), ("malformed", ErrorKind::Malformed), ("orderNotReady", ErrorKind::OrderNotReady), ("rateLimited", ErrorKind::RateLimited), @@ -336,6 +342,7 @@ impl Problem { ErrorKind::BadCsr | ErrorKind::Caa + | ErrorKind::InvalidProfile | ErrorKind::RejectedIdentifier | ErrorKind::UnsupportedIdentifier => ProblemCategory::Order, @@ -346,6 +353,18 @@ impl Problem { } } +/// Deserializes value of type T, while handling explicit `null` as a Default. +/// +/// This helper complements `#[serde(default)]`, which only works on omitted fields. +fn deserialize_null_as_default<'de, D, T>(deserializer: D) -> Result +where + D: serde::de::Deserializer<'de>, + T: serde::de::Deserialize<'de> + Default, +{ + let val = Option::::deserialize(deserializer)?; + Ok(val.unwrap_or_default()) +} + fn deserialize_vec_of_uri<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, @@ -385,6 +404,62 @@ where mod tests { use super::*; + #[test] + fn directory() { + // complete example + let _: Directory = serde_json::from_str( + r#"{ + "newNonce": "https://example.com/acme/new-nonce", + "newAccount": "https://example.com/acme/new-account", + "newOrder": "https://example.com/acme/new-order", + "newAuthz": "https://example.com/acme/new-authz", + "revokeCert": "https://example.com/acme/revoke-cert", + "keyChange": "https://example.com/acme/key-change", + "meta": { + "termsOfService": "https://example.com/acme/terms/2017-5-30", + "website": "https://www.example.com/", + "caaIdentities": ["example.com"], + "externalAccountRequired": false, + "profiles": { + "profile1": "https://example.com/acme/docs/profiles#profile1", + "profile2": "https://example.com/acme/docs/profiles#profile2" + } + } + }"#, + ) + .unwrap(); + + // minimal + let _: Directory = serde_json::from_str( + r#"{ + "newNonce": "https://example.com/acme/new-nonce", + "newAccount": "https://example.com/acme/new-account", + "newOrder": "https://example.com/acme/new-order" + }"#, + ) + .unwrap(); + + // null + let _: Directory = serde_json::from_str( + r#"{ + "newNonce": "https://example.com/acme/new-nonce", + "newAccount": "https://example.com/acme/new-account", + "newOrder": "https://example.com/acme/new-order", + "newAuthz": null, + "revokeCert": null, + "keyChange": null, + "meta": { + "termsOfService": null, + "website": null, + "caaIdentities": null, + "externalAccountRequired": null, + "profiles": null + } + }"#, + ) + .unwrap(); + } + #[test] fn order() { let _order: Order = serde_json::from_str( diff --git a/src/conf.rs b/src/conf.rs index 6f437f9..05f41c3 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; 10] = [ +static mut NGX_HTTP_ACME_ISSUER_COMMANDS: [ngx_command_t; 11] = [ 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; 10] = [ offset: 0, post: ptr::null_mut(), }, + ngx_command_t { + name: ngx_string!("profile"), + type_: nginx_sys::NGX_CONF_TAKE12 as ngx_uint_t, + set: Some(cmd_issuer_set_profile), + conf: 0, + offset: 0, + post: ptr::null_mut(), + }, ngx_command_t { name: ngx_string!("ssl_trusted_certificate"), type_: NGX_CONF_TAKE1 as ngx_uint_t, @@ -496,6 +504,42 @@ extern "C" fn cmd_issuer_set_external_account_key( NGX_CONF_OK } +extern "C" fn cmd_issuer_set_profile( + 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 !matches!(issuer.profile, issuer::Profile::Unset) { + return NGX_CONF_DUPLICATE; + } + + // NGX_CONF_TAKE12 ensures that args contains either 2 or 3 elements + let args = cf.args(); + + // SAFETY: the value is not empty, well aligned, and the conversion result is assigned to an + // object in the same pool. + let Ok(profile) = (unsafe { conf_value_to_str(&args[1]) }) else { + return NGX_CONF_INVALID_VALUE; + }; + + let require = match args.get(2) { + Some(x) if x.as_ref() == b"require" => true, + Some(_) => return NGX_CONF_INVALID_VALUE, + None => false, + }; + + issuer.profile = if require { + issuer::Profile::Required(profile) + } else { + issuer::Profile::Preferred(profile) + }; + + NGX_CONF_OK +} + extern "C" fn cmd_issuer_set_uri( cf: *mut ngx_conf_t, _cmd: *mut ngx_command_t, diff --git a/src/conf/issuer.rs b/src/conf/issuer.rs index fd17d2f..8d91d9d 100644 --- a/src/conf/issuer.rs +++ b/src/conf/issuer.rs @@ -50,6 +50,7 @@ pub struct Issuer { pub challenge: Option, pub contacts: Vec<&'static str, Pool>, pub eab_key: Option, + pub profile: Profile, pub resolver: Option>, pub resolver_timeout: ngx_msec_t, pub ssl_trusted_certificate: ngx_str_t, @@ -70,6 +71,13 @@ pub struct ExternalAccountKey { pub key: ngx_str_t, } +#[derive(Debug)] +pub enum Profile { + Preferred(&'static str), + Required(&'static str), + Unset, +} + #[derive(Debug, Error)] pub enum IssuerError { #[error("cannot load account key: {0}")] @@ -102,6 +110,7 @@ impl Issuer { challenge: None, contacts: Vec::new_in(alloc.clone()), eab_key: None, + profile: Profile::Unset, resolver: None, resolver_timeout: NGX_CONF_UNSET_MSEC, ssl_trusted_certificate: ngx_str_t::empty(), diff --git a/t/acme_profiles.t b/t/acme_profiles.t new file mode 100644 index 0000000..cb3ee1c --- /dev/null +++ b/t/acme_profiles.t @@ -0,0 +1,200 @@ +#!/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: ACME Profiles Extension. + +############################################################################### + +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 socket_ssl/) + ->has_daemon('openssl'); + +eval { require Date::Parse; }; +plan(skip_all => 'Date::Parse is not installed') if $@; + +eval { defined &Net::SSLeay::P_ASN1_TIME_get_isotime or die; }; +plan(skip_all => 'no P_ASN1_TIME_get_isotime, old Net::SSLeay') if $@; + +$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; + profile default; + ssl_trusted_certificate acme.test.crt; + state_path %%TESTDIR%%/acme_default; + accept_terms_of_service; + } + + acme_issuer shortlived { + uri https://acme.test:%%PORT_9000%%/dir; + profile shortlived require; + ssl_trusted_certificate acme.test.crt; + state_path %%TESTDIR%%/acme_shortlived; + accept_terms_of_service; + } + + server { + listen 127.0.0.1:8080; + server_name example.test; + } + + 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; + } + + server { + listen 127.0.0.1:8444 ssl; + server_name shortlived.test; + + acme_certificate shortlived; + + 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' }, + { name => 'example.test', A => '127.0.0.1' }, + { name => 'shortlived.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', + http_port => port(8080), + dns_port => $dp, + nosleep => 1, + conf => { + profiles => { + default => { + description => "The default profile", + validityPeriod => 777600, + }, + shortlived => { + description => "A short-lived cert profile", + validityPeriod => 86400, + }, + }, + }, +)->has(qw/profile/); + +$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(2)->run(); + +############################################################################### + +$acme->wait_certificate('acme_default/example.test') or die "no certificate"; +$acme->wait_certificate('acme_shortlived/shortlived.test') + or die "no certificate"; + +my $valid = get(8443, 'example.test', 'acme-root'); + +ok(defined $valid && $valid > 2 * 86400, 'default profile'); + +$valid = get(8444, 'shortlived.test', 'acme-root'); + +ok(defined $valid && $valid < 86400, 'shortlived profile'); + +############################################################################### + +sub get { + my ($port, $host, $ca) = @_; + + $ca = undef if $IO::Socket::SSL::VERSION < 2.062 + || !eval { Net::SSLeay::X509_V_FLAG_PARTIAL_CHAIN() }; + + my $s = http_get( + '/', start => 1, 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(), + ) : () + ); + + return $s unless $s; + + my $ssl = $s->_get_ssl_object(); + my $cert = Net::SSLeay::get_peer_certificate($ssl); + + return cert_validity($cert); +} + +sub cert_validity { + my ($cert) = @_; + + my $notAfter = Net::SSLeay::X509_get_notAfter($cert) or return; + $notAfter = Net::SSLeay::P_ASN1_TIME_get_isotime($notAfter) or return; + $notAfter = Date::Parse::str2time($notAfter) or return; + return $notAfter - time(); +} + +###############################################################################