Skip to content

Commit

Permalink
ACME External Account Binding support (closes #379 closes ##650)
Browse files Browse the repository at this point in the history
  • Loading branch information
mdecimus committed Oct 8, 2024
1 parent a1ca7fa commit 8ff2438
Show file tree
Hide file tree
Showing 6 changed files with 193 additions and 111 deletions.
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
38 changes: 36 additions & 2 deletions crates/common/src/config/server/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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,
};

Expand Down Expand Up @@ -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"))
Expand All @@ -141,6 +174,7 @@ impl AcmeProviders {
domains,
contact,
challenge,
eab,
renew_before,
default,
) {
Expand Down
70 changes: 43 additions & 27 deletions crates/common/src/listener/acme/directory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand All @@ -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,
Expand Down
84 changes: 56 additions & 28 deletions crates/common/src/listener/acme/jose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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,
};
signature: URL_SAFE_NO_PAD.encode(signature.as_ref()),
})
.map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err))
}

serde_json::to_string(&body)
.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,
})
}

pub(crate) fn key_authorization(key: &EcdsaKeyPair, token: &str) -> trc::Result<String> {
Expand All @@ -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,
Expand All @@ -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()))
}
}

Expand All @@ -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))?,
)))
}
}

Expand Down
11 changes: 11 additions & 0 deletions crates/common/src/listener/acme/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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> {
Expand All @@ -75,6 +84,7 @@ impl AcmeProvider {
domains,
account_key: Default::default(),
challenge,
eab,
default,
})
}
Expand Down Expand Up @@ -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,
}
}
Expand Down
Loading

0 comments on commit 8ff2438

Please sign in to comment.