Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/sanitizers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ env:
perl-IO-Socket-SSL
perl-Test-Harness
perl-Test-Simple
perl-TimeDate
perl-lib

jobs:
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`_
Expand Down
19 changes: 18 additions & 1 deletion src/acme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,6 +72,7 @@ where
log: NonNull<nginx_sys::ngx_log_t>,
key: AccountKey,
account: Option<String>,
profile: Option<&'a str>,
nonce: NoncePool,
directory: types::Directory,
solvers: Vec<Box<dyn solvers::ChallengeSolver + Send + 'a>>,
Expand Down Expand Up @@ -131,6 +132,7 @@ where
log,
key,
account: None,
profile: None,
nonce: Default::default(),
directory: Default::default(),
solvers: Vec::new(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)?;
Expand Down
77 changes: 76 additions & 1 deletion src/acme/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -22,6 +22,8 @@ pub struct DirectoryMetadata {
pub website: Option<Uri>,
pub caa_identities: Option<Vec<String>>,
pub external_account_required: Option<bool>,
#[serde(deserialize_with = "deserialize_null_as_default")]
pub profiles: std::collections::BTreeMap<String, IgnoredAny>,
}

/// RFC8555 Section 7.1.1 Directory
Expand Down Expand Up @@ -118,6 +120,8 @@ pub struct OrderRequest<'a> {
pub not_before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_after: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<&'a str>,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
Expand Down Expand Up @@ -200,6 +204,7 @@ pub enum ErrorKind {
ExternalAccountRequired,
IncorrectResponse,
InvalidContact,
InvalidProfile,
Malformed,
OrderNotReady,
RateLimited,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -336,6 +342,7 @@ impl Problem {

ErrorKind::BadCsr
| ErrorKind::Caa
| ErrorKind::InvalidProfile
| ErrorKind::RejectedIdentifier
| ErrorKind::UnsupportedIdentifier => ProblemCategory::Order,

Expand All @@ -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<T, D::Error>
where
D: serde::de::Deserializer<'de>,
T: serde::de::Deserialize<'de> + Default,
{
let val = Option::<T>::deserialize(deserializer)?;
Ok(val.unwrap_or_default())
}

fn deserialize_vec_of_uri<'de, D>(deserializer: D) -> Result<Vec<Uri>, D::Error>
where
D: serde::Deserializer<'de>,
Expand Down Expand Up @@ -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(
Expand Down
46 changes: 45 additions & 1 deletion src/conf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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::<Issuer>().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,
Expand Down
9 changes: 9 additions & 0 deletions src/conf/issuer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub struct Issuer {
pub challenge: Option<ChallengeKind>,
pub contacts: Vec<&'static str, Pool>,
pub eab_key: Option<ExternalAccountKey>,
pub profile: Profile,
pub resolver: Option<NonNull<ngx_resolver_t>>,
pub resolver_timeout: ngx_msec_t,
pub ssl_trusted_certificate: ngx_str_t,
Expand All @@ -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}")]
Expand Down Expand Up @@ -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(),
Expand Down
Loading