ACME External Account Binding support (closes #379 closes ##650)

This commit is contained in:
mdecimus 2024-10-08 16:28:03 +02:00
parent a1ca7fa849
commit 8ff2438f04
6 changed files with 193 additions and 111 deletions

View file

@ -21,8 +21,6 @@
<a href="https://mastodon.social/@stalwartlabs"><img src="https://img.shields.io/mastodon/follow/109929667531941122?style=flat-square&logo=mastodon&color=%236364ff&label=Follow%20on%20Mastodon" alt="Mastodon"></a>
&nbsp;
<a href="https://twitter.com/stalwartlabs"><img src="https://img.shields.io/twitter/follow/stalwartlabs?style=flat-square&logo=x&label=Follow%20on%20Twitter" alt="Twitter"></a>
&nbsp;
<a href="nostr:npub167hk2ermhky3pmudc3q0d2vnnhcesdgsrcqgywv447ls4xs5u89q5d6395"><img src="https://img.shields.io/nostr-band/followers/npub167hk2ermhky3pmudc3q0d2vnnhcesdgsrcqgywv447ls4xs5u89q5d6395?style=flat-square&logo=chatbot&label=Follow%20on%20Nostr" alt="Nostr"></a>
</p>
<p align="center">
<a href="https://discord.gg/jtgtCNj66U"><img src="https://img.shields.io/discord/923615863037390889?label=Join%20Discord&logo=discord&style=flat-square" alt="Discord"></a>

View file

@ -12,7 +12,10 @@ use std::{
};
use ahash::{AHashMap, AHashSet};
use base64::{engine::general_purpose::STANDARD, Engine};
use base64::{
engine::general_purpose::{self, STANDARD},
Engine,
};
use dns_update::{providers::rfc2136::DnsAddress, DnsUpdater, TsigAlgorithm};
use rcgen::generate_simple_self_signed;
use rustls::{
@ -31,7 +34,9 @@ use x509_parser::{
};
use crate::listener::{
acme::{directory::LETS_ENCRYPT_PRODUCTION_DIRECTORY, AcmeProvider, ChallengeSettings},
acme::{
directory::LETS_ENCRYPT_PRODUCTION_DIRECTORY, AcmeProvider, ChallengeSettings, EabSettings,
},
tls::AcmeProviders,
};
@ -129,6 +134,34 @@ impl AcmeProviders {
continue 'outer;
}
// Obtain EAB settings
let eab = if let (Some(eab_kid), Some(eab_hmac_key)) = (
config
.value(("acme", acme_id, "eab.kid"))
.filter(|s| !s.is_empty()),
config
.value(("acme", acme_id, "eab.hmac-key"))
.filter(|s| !s.is_empty()),
) {
if let Ok(hmac_key) =
general_purpose::URL_SAFE_NO_PAD.decode(eab_hmac_key.trim().as_bytes())
{
EabSettings {
kid: eab_kid.to_string(),
hmac_key,
}
.into()
} else {
config.new_build_error(
format!("acme.{acme_id}.eab.hmac-key"),
"Failed to base64 decode HMAC key",
);
None
}
} else {
None
};
// This ACME manager is the default when SNI is not available
let default = config
.property::<bool>(("acme", acme_id, "default"))
@ -141,6 +174,7 @@ impl AcmeProviders {
domains,
contact,
challenge,
eab,
renew_before,
default,
) {

View file

@ -10,14 +10,16 @@ use reqwest::{Method, Response};
use ring::rand::SystemRandom;
use ring::signature::{EcdsaKeyPair, EcdsaSigningAlgorithm, ECDSA_P256_SHA256_FIXED_SIGNING};
use serde::Deserialize;
use serde_json::json;
use store::write::Bincode;
use store::Serialize;
use trc::event::conv::AssertSuccess;
use trc::AddContext;
use super::jose::{
key_authorization, key_authorization_sha256, key_authorization_sha256_base64, sign,
eab_sign, key_authorization, key_authorization_sha256, key_authorization_sha256_base64, sign,
Body,
};
use super::AcmeProvider;
pub const LETS_ENCRYPT_STAGING_DIRECTORY: &str =
"https://acme-staging-v02.api.letsencrypt.org/directory";
@ -32,6 +34,16 @@ pub struct Account {
pub kid: String,
}
#[derive(Debug, serde::Serialize)]
pub struct NewAccountPayload<'x> {
#[serde(rename = "termsOfServiceAgreed")]
tos_agreed: bool,
contact: &'x [String],
#[serde(rename = "externalAccountBinding")]
#[serde(skip_serializing_if = "Option::is_none")]
eab: Option<Body>,
}
static ALG: &EcdsaSigningAlgorithm = &ECDSA_P256_SHA256_FIXED_SIGNING;
impl Account {
@ -42,35 +54,39 @@ impl Account {
.to_vec()
}
pub async fn create<'a, S, I>(directory: Directory, contact: I) -> trc::Result<Self>
where
S: AsRef<str> + 'a,
I: IntoIterator<Item = &'a S>,
{
Self::create_with_keypair(directory, contact, &Self::generate_key_pair()).await
pub async fn create(directory: Directory, provider: &AcmeProvider) -> trc::Result<Self> {
Self::create_with_keypair(directory, provider).await
}
pub async fn create_with_keypair<'a, S, I>(
pub async fn create_with_keypair(
directory: Directory,
contact: I,
key_pair: &[u8],
) -> trc::Result<Self>
where
S: AsRef<str> + 'a,
I: IntoIterator<Item = &'a S>,
{
let key_pair =
EcdsaKeyPair::from_pkcs8(ALG, key_pair, &SystemRandom::new()).map_err(|err| {
trc::EventType::Acme(trc::AcmeEvent::Error)
.reason(err)
.caused_by(trc::location!())
})?;
let contact: Vec<&'a str> = contact.into_iter().map(AsRef::<str>::as_ref).collect();
let payload = json!({
"termsOfServiceAgreed": true,
"contact": contact,
provider: &AcmeProvider,
) -> trc::Result<Self> {
let key_pair = EcdsaKeyPair::from_pkcs8(
ALG,
provider.account_key.load().as_slice(),
&SystemRandom::new(),
)
.map_err(|err| {
trc::EventType::Acme(trc::AcmeEvent::Error)
.reason(err)
.caused_by(trc::location!())
})?;
let eab = if let Some(eab) = &provider.eab {
eab_sign(&key_pair, &eab.kid, &eab.hmac_key, &directory.new_account)
.caused_by(trc::location!())?
.into()
} else {
None
};
let payload = serde_json::to_string(&NewAccountPayload {
tos_agreed: true,
contact: &provider.contact,
eab,
})
.to_string();
.unwrap_or_default();
let body = sign(
&key_pair,
None,

View file

@ -3,6 +3,7 @@
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use ring::digest::{digest, Digest, SHA256};
use ring::hmac;
use ring::rand::SystemRandom;
use ring::signature::{EcdsaKeyPair, KeyPair};
use serde::Serialize;
@ -18,7 +19,7 @@ pub(crate) fn sign(
None => Some(Jwk::new(key)),
Some(_) => None,
};
let protected = Protected::base64(jwk, kid, nonce, url)?;
let protected = Protected::encode("ES256", jwk, kid, nonce.into(), url)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let combined = format!("{}.{}", &protected, &payload);
let signature = key
@ -28,15 +29,34 @@ pub(crate) fn sign(
.caused_by(trc::location!())
.reason(err)
})?;
let signature = URL_SAFE_NO_PAD.encode(signature.as_ref());
let body = Body {
serde_json::to_string(&Body {
protected,
payload,
signature: URL_SAFE_NO_PAD.encode(signature.as_ref()),
})
.map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err))
}
pub(crate) fn eab_sign(
key: &EcdsaKeyPair,
kid: &str,
hmac_key: &[u8],
url: &str,
) -> trc::Result<Body> {
let protected = Protected::encode("HS256", None, kid.into(), None, url)?;
let payload = Jwk::new(key).base64()?;
let combined = format!("{}.{}", &protected, &payload);
let key = hmac::Key::new(hmac::HMAC_SHA256, hmac_key);
let tag = hmac::sign(&key, combined.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(tag.as_ref());
Ok(Body {
protected,
payload,
signature,
};
serde_json::to_string(&body)
.map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err))
})
}
pub(crate) fn key_authorization(key: &EcdsaKeyPair, token: &str) -> trc::Result<String> {
@ -58,8 +78,8 @@ pub(crate) fn key_authorization_sha256_base64(
key_authorization_sha256(key, token).map(|s| URL_SAFE_NO_PAD.encode(s.as_ref()))
}
#[derive(Serialize)]
struct Body {
#[derive(Debug, Serialize)]
pub(crate) struct Body {
protected: String,
payload: String,
signature: String,
@ -72,27 +92,28 @@ struct Protected<'a> {
jwk: Option<Jwk>,
#[serde(skip_serializing_if = "Option::is_none")]
kid: Option<&'a str>,
nonce: String,
#[serde(skip_serializing_if = "Option::is_none")]
nonce: Option<String>,
url: &'a str,
}
impl<'a> Protected<'a> {
fn base64(
fn encode(
alg: &'static str,
jwk: Option<Jwk>,
kid: Option<&'a str>,
nonce: String,
nonce: Option<String>,
url: &'a str,
) -> trc::Result<String> {
let protected = Self {
alg: "ES256",
serde_json::to_vec(&Protected {
alg,
jwk,
kid,
nonce,
url,
};
let protected = serde_json::to_vec(&protected)
.map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err))?;
Ok(URL_SAFE_NO_PAD.encode(protected))
})
.map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err))
.map(|v| URL_SAFE_NO_PAD.encode(v.as_slice()))
}
}
@ -119,17 +140,24 @@ impl Jwk {
y: URL_SAFE_NO_PAD.encode(y),
}
}
pub(crate) fn base64(&self) -> trc::Result<String> {
serde_json::to_vec(self)
.map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err))
.map(|v| URL_SAFE_NO_PAD.encode(v.as_slice()))
}
pub(crate) fn thumb_sha256_base64(&self) -> trc::Result<String> {
let jwk_thumb = JwkThumb {
crv: self.crv,
kty: self.kty,
x: &self.x,
y: &self.y,
};
let json = serde_json::to_vec(&jwk_thumb)
.map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err))?;
let hash = digest(&SHA256, &json);
Ok(URL_SAFE_NO_PAD.encode(hash))
Ok(URL_SAFE_NO_PAD.encode(digest(
&SHA256,
&serde_json::to_vec(&JwkThumb {
crv: self.crv,
kty: self.kty,
x: &self.x,
y: &self.y,
})
.map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err))?,
)))
}
}

View file

@ -26,11 +26,18 @@ pub struct AcmeProvider {
pub domains: Vec<String>,
pub contact: Vec<String>,
pub challenge: ChallengeSettings,
pub eab: Option<EabSettings>,
renew_before: chrono::Duration,
account_key: ArcSwap<Vec<u8>>,
default: bool,
}
#[derive(Clone)]
pub struct EabSettings {
pub kid: String,
pub hmac_key: Vec<u8>,
}
#[derive(Clone)]
pub enum ChallengeSettings {
Http01,
@ -49,12 +56,14 @@ pub struct StaticResolver {
}
impl AcmeProvider {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: String,
directory_url: String,
domains: Vec<String>,
contact: Vec<String>,
challenge: ChallengeSettings,
eab: Option<EabSettings>,
renew_before: Duration,
default: bool,
) -> trc::Result<Self> {
@ -75,6 +84,7 @@ impl AcmeProvider {
domains,
account_key: Default::default(),
challenge,
eab,
default,
})
}
@ -142,6 +152,7 @@ impl Clone for AcmeProvider {
challenge: self.challenge.clone(),
renew_before: self.renew_before,
account_key: ArcSwap::from_pointee(self.account_key.load().as_ref().clone()),
eab: self.eab.clone(),
default: self.default,
}
}

View file

@ -9,6 +9,7 @@ use rustls::sign::CertifiedKey;
use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use std::sync::Arc;
use std::time::{Duration, Instant};
use trc::{AcmeEvent, EventType};
use x509_parser::parse_x509_certificate;
use crate::listener::acme::directory::Identifier;
@ -36,7 +37,7 @@ impl Server {
let renewal_date = validity[1] - provider.renew_before;
trc::event!(
Acme(trc::AcmeEvent::ProcessCert),
Acme(AcmeEvent::ProcessCert),
Id = provider.id.to_string(),
Hostname = provider.domains.as_slice(),
ValidFrom = trc::Value::Timestamp(validity[0].timestamp() as u64),
@ -56,16 +57,18 @@ impl Server {
loop {
match self.order(provider).await {
Ok(pem) => return self.process_cert(provider, pem, false).await,
Err(err) if backoff < 16 => {
Err(err)
if !err.matches(EventType::Acme(AcmeEvent::OrderInvalid)) && backoff < 16 =>
{
trc::event!(
Acme(trc::AcmeEvent::RenewBackoff),
Acme(AcmeEvent::RenewBackoff),
Id = provider.id.to_string(),
Hostname = provider.domains.as_slice(),
Total = backoff,
NextRetry = 1 << backoff,
CausedBy = err,
);
backoff = (backoff + 1).min(16);
backoff += 1;
tokio::time::sleep(Duration::from_secs(1 << backoff)).await;
}
Err(err) => {
@ -80,18 +83,13 @@ impl Server {
async fn order(&self, provider: &AcmeProvider) -> trc::Result<Vec<u8>> {
let directory = Directory::discover(&provider.directory_url).await?;
let account = Account::create_with_keypair(
directory,
&provider.contact,
provider.account_key.load().as_slice(),
)
.await?;
let account = Account::create_with_keypair(directory, provider).await?;
let mut params = CertificateParams::new(provider.domains.clone());
params.distinguished_name = DistinguishedName::new();
params.alg = &PKCS_ECDSA_P256_SHA256;
let cert = rcgen::Certificate::from_params(params).map_err(|err| {
trc::EventType::Acme(trc::AcmeEvent::Error)
EventType::Acme(AcmeEvent::Error)
.caused_by(trc::location!())
.reason(err)
})?;
@ -106,7 +104,7 @@ impl Server {
.map(|url| self.authorize(provider, &account, url));
try_join_all(auth_futures).await?;
trc::event!(
Acme(trc::AcmeEvent::AuthCompleted),
Acme(AcmeEvent::AuthCompleted),
Id = provider.id.to_string(),
Hostname = provider.domains.as_slice(),
);
@ -115,7 +113,7 @@ impl Server {
OrderStatus::Processing => {
for i in 0u64..10 {
trc::event!(
Acme(trc::AcmeEvent::OrderProcessing),
Acme(AcmeEvent::OrderProcessing),
Id = provider.id.to_string(),
Hostname = provider.domains.as_slice(),
Total = i,
@ -128,20 +126,20 @@ impl Server {
}
}
if order.status == OrderStatus::Processing {
return Err(trc::EventType::Acme(trc::AcmeEvent::Error)
return Err(EventType::Acme(AcmeEvent::Error)
.caused_by(trc::location!())
.details("Order processing timed out"));
}
}
OrderStatus::Ready => {
trc::event!(
Acme(trc::AcmeEvent::OrderReady),
Acme(AcmeEvent::OrderReady),
Id = provider.id.to_string(),
Hostname = provider.domains.as_slice(),
);
let csr = cert.serialize_request_der().map_err(|err| {
trc::EventType::Acme(trc::AcmeEvent::Error)
EventType::Acme(AcmeEvent::Error)
.caused_by(trc::location!())
.reason(err)
})?;
@ -149,7 +147,7 @@ impl Server {
}
OrderStatus::Valid { certificate } => {
trc::event!(
Acme(trc::AcmeEvent::OrderValid),
Acme(AcmeEvent::OrderValid),
Id = provider.id.to_string(),
Hostname = provider.domains.as_slice(),
);
@ -163,15 +161,7 @@ impl Server {
return Ok(pem.into_bytes());
}
OrderStatus::Invalid => {
trc::event!(
Acme(trc::AcmeEvent::OrderInvalid),
Id = provider.id.to_string(),
Hostname = provider.domains.as_slice(),
);
return Err(trc::EventType::Acme(trc::AcmeEvent::Error)
.into_err()
.details("Invalid ACME order"));
return Err(EventType::Acme(AcmeEvent::OrderInvalid).into_err());
}
}
}
@ -190,7 +180,7 @@ impl Server {
let challenge_type = provider.challenge.challenge_type();
trc::event!(
Acme(trc::AcmeEvent::AuthStart),
Acme(AcmeEvent::AuthStart),
Hostname = domain.to_string(),
Type = challenge_type.as_str(),
Id = provider.id.to_string(),
@ -201,11 +191,18 @@ impl Server {
.iter()
.find(|c| c.typ == challenge_type)
.ok_or(
trc::EventType::Acme(trc::AcmeEvent::Error)
EventType::Acme(AcmeEvent::OrderInvalid)
.into_err()
.details("Missing Parameter")
.details("Challenge not supported by ACME provider")
.ctx(trc::Key::Id, provider.id.to_string())
.ctx(trc::Key::Type, challenge_type.as_str()),
.ctx(trc::Key::Type, challenge_type.as_str())
.ctx(
trc::Key::Contents,
auth.challenges
.iter()
.map(|c| trc::Value::Static(c.typ.as_str()))
.collect::<Vec<_>>(),
),
)?;
match &provider.challenge {
@ -247,7 +244,7 @@ impl Server {
if let Err(err) = updater.delete(&name, &origin).await {
// Errors are expected if the record does not exist
trc::event!(
Acme(trc::AcmeEvent::DnsRecordDeletionFailed),
Acme(AcmeEvent::DnsRecordDeletionFailed),
Hostname = name.to_string(),
Reason = err.to_string(),
Details = origin.to_string(),
@ -267,17 +264,15 @@ impl Server {
)
.await
{
return Err(trc::EventType::Acme(
trc::AcmeEvent::DnsRecordCreationFailed,
)
.ctx(trc::Key::Id, provider.id.to_string())
.ctx(trc::Key::Hostname, name)
.ctx(trc::Key::Details, origin)
.reason(err));
return Err(EventType::Acme(AcmeEvent::DnsRecordCreationFailed)
.ctx(trc::Key::Id, provider.id.to_string())
.ctx(trc::Key::Hostname, name)
.ctx(trc::Key::Details, origin)
.reason(err));
}
trc::event!(
Acme(trc::AcmeEvent::DnsRecordCreated),
Acme(AcmeEvent::DnsRecordCreated),
Hostname = name.to_string(),
Details = origin.to_string(),
Id = provider.id.to_string(),
@ -295,7 +290,7 @@ impl Server {
break;
} else {
trc::event!(
Acme(trc::AcmeEvent::DnsRecordNotPropagated),
Acme(AcmeEvent::DnsRecordNotPropagated),
Id = provider.id.to_string(),
Hostname = name.to_string(),
Details = origin.to_string(),
@ -306,7 +301,7 @@ impl Server {
}
Err(err) => {
trc::event!(
Acme(trc::AcmeEvent::DnsRecordLookupFailed),
Acme(AcmeEvent::DnsRecordLookupFailed),
Id = provider.id.to_string(),
Hostname = name.to_string(),
Details = origin.to_string(),
@ -320,14 +315,14 @@ impl Server {
if did_propagate {
trc::event!(
Acme(trc::AcmeEvent::DnsRecordPropagated),
Acme(AcmeEvent::DnsRecordPropagated),
Id = provider.id.to_string(),
Hostname = name.to_string(),
Details = origin.to_string(),
);
} else {
trc::event!(
Acme(trc::AcmeEvent::DnsRecordPropagationTimeout),
Acme(AcmeEvent::DnsRecordPropagationTimeout),
Id = provider.id.to_string(),
Hostname = name.to_string(),
Details = origin.to_string(),
@ -341,7 +336,7 @@ impl Server {
}
AuthStatus::Valid => return Ok(()),
_ => {
return Err(trc::EventType::Acme(trc::AcmeEvent::AuthError)
return Err(EventType::Acme(AcmeEvent::AuthError)
.into_err()
.ctx(trc::Key::Id, provider.id.to_string())
.ctx(trc::Key::Details, auth.status.as_str()))
@ -354,7 +349,7 @@ impl Server {
match auth.status {
AuthStatus::Pending => {
trc::event!(
Acme(trc::AcmeEvent::AuthPending),
Acme(AcmeEvent::AuthPending),
Hostname = domain.to_string(),
Id = provider.id.to_string(),
Total = i,
@ -364,7 +359,7 @@ impl Server {
}
AuthStatus::Valid => {
trc::event!(
Acme(trc::AcmeEvent::AuthValid),
Acme(AcmeEvent::AuthValid),
Hostname = domain.to_string(),
Id = provider.id.to_string(),
);
@ -372,14 +367,14 @@ impl Server {
return Ok(());
}
_ => {
return Err(trc::EventType::Acme(trc::AcmeEvent::AuthError)
return Err(EventType::Acme(AcmeEvent::AuthError)
.into_err()
.ctx(trc::Key::Id, provider.id.to_string())
.ctx(trc::Key::Details, auth.status.as_str()))
}
}
}
Err(trc::EventType::Acme(trc::AcmeEvent::AuthTooManyAttempts)
Err(EventType::Acme(AcmeEvent::AuthTooManyAttempts)
.into_err()
.ctx(trc::Key::Id, provider.id.to_string())
.ctx(trc::Key::Hostname, domain))
@ -388,12 +383,12 @@ impl Server {
fn parse_cert(pem: &[u8]) -> trc::Result<(CertifiedKey, [DateTime<Utc>; 2])> {
let mut pems = pem::parse_many(pem).map_err(|err| {
trc::EventType::Acme(trc::AcmeEvent::Error)
EventType::Acme(AcmeEvent::Error)
.reason(err)
.caused_by(trc::location!())
})?;
if pems.len() < 2 {
return Err(trc::EventType::Acme(trc::AcmeEvent::Error)
return Err(EventType::Acme(AcmeEvent::Error)
.caused_by(trc::location!())
.ctx(trc::Key::Size, pems.len())
.details("Too few PEMs"));
@ -403,7 +398,7 @@ fn parse_cert(pem: &[u8]) -> trc::Result<(CertifiedKey, [DateTime<Utc>; 2])> {
))) {
Ok(pk) => pk,
Err(err) => {
return Err(trc::EventType::Acme(trc::AcmeEvent::Error)
return Err(EventType::Acme(AcmeEvent::Error)
.reason(err)
.caused_by(trc::location!()))
}
@ -422,7 +417,7 @@ fn parse_cert(pem: &[u8]) -> trc::Result<(CertifiedKey, [DateTime<Utc>; 2])> {
})
}
Err(err) => {
return Err(trc::EventType::Acme(trc::AcmeEvent::Error)
return Err(EventType::Acme(AcmeEvent::Error)
.reason(err)
.caused_by(trc::location!()))
}