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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ 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

[NGINX]: https://nginx.org/
[RFC8555]: https://www.rfc-editor.org/rfc/rfc8555.html
Expand Down Expand Up @@ -179,6 +178,22 @@ regarding account issues.
The `mailto:` scheme will be assumed unless specified
explicitly.

### external_account_key

**Syntax:** external_account_key `kid` `file`

**Default:** -

**Context:** acme_issuer

A key identifier and a file with the MAC key for external account authorization
([RFC8555 § 7.3.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3.4)).

The value `data:key` can be specified instead of the `file` to load the key
directly from the configuration without using intermediate files.

In both cases, the key is expected to be encoded as base64url.

### ssl_trusted_certificate

**Syntax:** ssl_trusted_certificate `file`
Expand Down
37 changes: 28 additions & 9 deletions src/acme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,11 @@ where
&self.key,
self.account.as_deref(),
&url.to_string(),
&nonce,
Some(&nonce),
payload.as_ref(),
)?;
)?
.to_string();

let req = http::Request::builder()
.uri(url)
.method(http::Method::POST)
Expand Down Expand Up @@ -227,17 +229,34 @@ where
pub async fn new_account(&mut self) -> Result<types::Account> {
self.directory = self.get_directory().await?;

// We validate that the strings are valid UTF-8 at configuration time.
let contact: Vec<&str> = self
if self.directory.meta.external_account_required == Some(true)
&& self.issuer.eab_key.is_none()
{
return Err(anyhow!("external account key required"));
}

let external_account_binding = self
.issuer
.contacts
.iter()
.map(|x| x.to_str())
.collect::<Result<_, _>>()?;
.eab_key
.as_ref()
.map(|x| -> Result<_> {
let key = crate::jws::ShaWithHmacKey::new(&x.key, 256);
let payload = serde_json::to_vec(&self.key)?;
let message = crate::jws::sign_jws(
&key,
Some(x.kid),
&self.directory.new_account.to_string(),
None,
&payload,
)?;
Ok(message)
})
.transpose()?;

let payload = types::AccountRequest {
terms_of_service_agreed: self.issuer.accept_tos,
contact,
contact: &self.issuer.contacts,
external_account_binding,

..Default::default()
};
Expand Down
7 changes: 4 additions & 3 deletions src/acme/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,14 @@ pub struct Account {
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountRequest<'a> {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub contact: Vec<&'a str>,
#[serde(skip_serializing_if = "<[_]>::is_empty")]
pub contact: &'a [&'a str],
#[serde(skip_serializing_if = "Option::is_none")]
pub terms_of_service_agreed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub only_return_existing: Option<bool>,
// external_account_binding: Option<JWS>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_account_binding: Option<crate::jws::SignedMessage>,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
Expand Down
119 changes: 107 additions & 12 deletions src/conf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ use core::ffi::{c_char, c_void, CStr};
use core::{mem, ptr};

use nginx_sys::{
ngx_command_t, ngx_conf_parse, ngx_conf_t, ngx_http_core_srv_conf_t, ngx_str_t, ngx_uint_t,
NGX_CONF_1MORE, NGX_CONF_BLOCK, NGX_CONF_FLAG, NGX_CONF_NOARGS, NGX_CONF_TAKE1,
NGX_HTTP_MAIN_CONF, NGX_HTTP_MAIN_CONF_OFFSET, NGX_HTTP_SRV_CONF, NGX_HTTP_SRV_CONF_OFFSET,
NGX_LOG_EMERG,
ngx_command_t, ngx_conf_parse, ngx_conf_t, ngx_decode_base64url, ngx_http_core_srv_conf_t,
ngx_str_t, ngx_uint_t, NGX_CONF_1MORE, NGX_CONF_BLOCK, NGX_CONF_FLAG, NGX_CONF_NOARGS,
NGX_CONF_TAKE1, NGX_CONF_TAKE2, NGX_HTTP_MAIN_CONF, NGX_HTTP_MAIN_CONF_OFFSET,
NGX_HTTP_SRV_CONF, NGX_HTTP_SRV_CONF_OFFSET, NGX_LOG_EMERG,
};
use ngx::collections::Vec;
use ngx::core::{Pool, Status, NGX_CONF_ERROR, NGX_CONF_OK};
Expand All @@ -33,6 +33,7 @@ pub mod shared_zone;
pub mod ssl;

const NGX_CONF_DUPLICATE: *mut c_char = c"is duplicate".as_ptr().cast_mut();
const NGX_CONF_INVALID_VALUE: *mut c_char = c"invalid value".as_ptr().cast_mut();

/// Main (http block) level configuration.
#[derive(Debug, Default)]
Expand Down Expand Up @@ -79,7 +80,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,
Expand All @@ -104,6 +105,14 @@ static mut NGX_HTTP_ACME_ISSUER_COMMANDS: [ngx_command_t; 8] = [
offset: 0,
post: ptr::null_mut(),
},
ngx_command_t {
name: ngx_string!("external_account_key"),
type_: NGX_CONF_TAKE2 as ngx_uint_t,
set: Some(cmd_issuer_set_external_account_key),
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 @@ -337,12 +346,12 @@ extern "C" fn cmd_issuer_add_contact(
// NGX_CONF_TAKE1 ensures that args contains 2 elements
let args = cf.args();

if args[1].is_empty() || core::str::from_utf8(args[1].as_bytes()).is_err() {
return c"invalid value".as_ptr().cast_mut();
if args[1].is_empty() {
return NGX_CONF_INVALID_VALUE;
};

if has_scheme(args[1].as_ref()) {
issuer.contacts.push(args[1]);
let value = if has_scheme(args[1].as_ref()) {
args[1]
} else {
let mut value = ngx_str_t::empty();
value.len = MAILTO.len() + args[1].len;
Expand All @@ -353,11 +362,18 @@ extern "C" fn cmd_issuer_add_contact(

value.as_bytes_mut()[..MAILTO.len()].copy_from_slice(MAILTO);
value.as_bytes_mut()[MAILTO.len()..].copy_from_slice(args[1].as_ref());
value
};

issuer.contacts.push(value);
// SAFETY: the value is not empty, well aligned, and the conversion result is assigned to an
// object in the same pool.
match unsafe { conf_value_to_str(&value) } {
Ok(x) => {
issuer.contacts.push(x);
NGX_CONF_OK
}
Err(_) => NGX_CONF_INVALID_VALUE,
}

NGX_CONF_OK
}

extern "C" fn cmd_issuer_set_account_key(
Expand All @@ -383,6 +399,62 @@ extern "C" fn cmd_issuer_set_account_key(
NGX_CONF_OK
}

extern "C" fn cmd_issuer_set_external_account_key(
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 issuer.eab_key.is_some() {
return NGX_CONF_DUPLICATE;
}

let mut pool = cf.pool();
// NGX_CONF_TAKE2 ensures that args contains 3 elements
let args = cf.args();

if args[1].is_empty() || args[2].is_empty() {
return NGX_CONF_INVALID_VALUE;
}

// SAFETY: the value is not empty, well aligned, and the conversion result is assigned to an
// object in the same pool.
let Ok(kid) = (unsafe { conf_value_to_str(&args[1]) }) else {
return NGX_CONF_INVALID_VALUE;
};

let mut encoded = if let Some(arg) = args[2].strip_prefix(b"data:") {
arg
} else {
match crate::util::read_to_ngx_str(cf, &args[2]) {
Ok(x) => x,
Err(e) => return cf.error(args[0], &e),
}
};

crate::util::ngx_str_trim(&mut encoded);

let len = encoded.len.div_ceil(4) * 3;
let mut key = ngx_str_t {
data: pool.alloc_unaligned(len).cast(),
len,
};

if key.data.is_null() {
return NGX_CONF_ERROR;
}

if !Status(unsafe { ngx_decode_base64url(&mut key, &mut encoded) }).is_ok() {
return c"invalid base64url encoded value".as_ptr().cast_mut();
}

issuer.eab_key = Some(issuer::ExternalAccountKey { kid, key });

NGX_CONF_OK
}

extern "C" fn cmd_issuer_set_uri(
cf: *mut ngx_conf_t,
_cmd: *mut ngx_command_t,
Expand Down Expand Up @@ -566,3 +638,26 @@ fn conf_check_nargs(cmd: &ngx_command_t, nargs: ngx_uint_t) -> bool {
nargs <= ARGUMENT_NUMBER.len() && (flags & ARGUMENT_NUMBER[nargs - 1]) != 0
}
}

/// Unsafely converts `ngx_str_t` into a static UTF-8 string reference.
///
/// # Safety
///
/// `value` must be allocated on the configuration (cycle) pool, and stored in another object on the
/// same pool. With that, we can expect that both the borrowed string and the owning object will be
/// destroyed simultaneously.
///
/// In the worker process this happens at the process exit, making the `'static` lifetime specifier
/// accurate.
/// In the master process, the cycle pool is destroyed after reloading the configuration, along with
/// all the configuration objects. But this process role is not capable of serving connections or
/// running background tasks, and thus will not create additional borrows with potentially extended
/// lifetime.
unsafe fn conf_value_to_str(value: &ngx_str_t) -> Result<&'static str, core::str::Utf8Error> {
if value.len == 0 {
Ok("")
} else {
let bytes = core::slice::from_raw_parts(value.data, value.len);
core::str::from_utf8(bytes)
}
}
10 changes: 9 additions & 1 deletion src/conf/issuer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ pub struct Issuer {
pub name: ngx_str_t,
pub uri: Uri,
pub account_key: PrivateKey,
pub contacts: Vec<ngx_str_t, Pool>,
pub contacts: Vec<&'static str, Pool>,
pub eab_key: Option<ExternalAccountKey>,
pub resolver: Option<NonNull<ngx_resolver_t>>,
pub resolver_timeout: ngx_msec_t,
pub ssl_trusted_certificate: ngx_str_t,
Expand All @@ -58,6 +59,12 @@ pub struct Issuer {
pub data: Option<&'static RwLock<IssuerContext>>,
}

#[derive(Debug)]
pub struct ExternalAccountKey {
pub kid: &'static str,
pub key: ngx_str_t,
}

#[derive(Debug, Error)]
pub enum IssuerError {
#[error("cannot load account key: {0}")]
Expand Down Expand Up @@ -88,6 +95,7 @@ impl Issuer {
uri: Default::default(),
account_key: PrivateKey::Unset,
contacts: Vec::new_in(alloc.clone()),
eab_key: None,
resolver: None,
resolver_timeout: NGX_CONF_UNSET_MSEC,
ssl_trusted_certificate: ngx_str_t::empty(),
Expand Down
Loading
Loading