From a155f819bc5c611c78e2363578e901c341a5e89e Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:36:28 +0200 Subject: [PATCH] DNS, DKIM and ACME improvements - part 1 --- CHANGELOG.md | 25 +- Cargo.lock | 236 +++++++--- crates/common/Cargo.toml | 2 +- crates/common/src/config/mod.rs | 50 +- crates/common/src/config/server/tls.rs | 409 ++++++++-------- crates/common/src/config/smtp/auth.rs | 12 +- crates/common/src/ipc.rs | 2 +- crates/common/src/network/acme/account.rs | 95 ++++ crates/common/src/network/acme/cache.rs | 126 ----- crates/common/src/network/acme/directory.rs | 375 +++++---------- crates/common/src/network/acme/http.rs | 85 ++++ crates/common/src/network/acme/jose.rs | 37 +- crates/common/src/network/acme/mod.rs | 299 +++++++----- crates/common/src/network/acme/order.rs | 437 +++++++++--------- crates/common/src/network/acme/renew.rs | 141 ++++++ crates/common/src/network/acme/resolver.rs | 45 +- crates/common/src/network/dkim.rs | 162 +++++++ crates/jmap/src/registry/mapping/dkim.rs | 39 +- crates/jmap/src/registry/mapping/domain.rs | 137 ++++++ crates/jmap/src/registry/mapping/mod.rs | 2 + crates/jmap/src/registry/mapping/principal.rs | 4 + crates/jmap/src/registry/mapping/task.rs | 5 +- crates/jmap/src/registry/mapping/tls.rs | 106 +++++ crates/jmap/src/registry/set.rs | 72 ++- crates/registry/src/types/map.rs | 23 + crates/registry/src/utils/dkim.rs | 28 ++ crates/registry/src/utils/mod.rs | 20 +- crates/registry/src/utils/task.rs | 9 + crates/services/src/task_manager/manager.rs | 13 +- crates/services/src/task_manager/mod.rs | 3 + crates/trc/src/event/enums.rs | 8 +- crates/trc/src/event/enums_impl.rs | 22 +- crates/trc/src/event/mod.rs | 22 - tests/Cargo.toml | 2 +- 34 files changed, 1912 insertions(+), 1141 deletions(-) create mode 100644 crates/common/src/network/acme/account.rs delete mode 100644 crates/common/src/network/acme/cache.rs create mode 100644 crates/common/src/network/acme/http.rs create mode 100644 crates/common/src/network/acme/renew.rs create mode 100644 crates/jmap/src/registry/mapping/domain.rs create mode 100644 crates/jmap/src/registry/mapping/tls.rs create mode 100644 crates/registry/src/utils/dkim.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a9389064..13c0f8ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,6 @@ All notable changes to this project will be documented in this file. This projec This version includes **multiple breaking changes**. If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. ## Added -- Masked email addresses for enhanced privacy (Enterprise feature). - App password enhancements: - Limited access (#1609) - Labels (#2255) @@ -28,8 +27,7 @@ This version includes **multiple breaking changes**. If you are upgrading from v - RCPT TO stage settings improvements (#2217 #394) - OIDC: - JWT token validation without requesting userinfo from the OIDC provider. - - Audience (`aud`) claim support (#2603) - - Scope validation support. + - Audience (`aud`) claim (#2603) and scope validation support. - Groups support (#1448) - LDAP: - Separate filter for groups (#1841) @@ -37,15 +35,21 @@ This version includes **multiple breaking changes**. If you are upgrading from v - Improve and simplify LDAP settings (#2194 #2174) - DKIM: - Store DKIM keys in the database (#1264) +- DNS Management: + - RFC2136 SIG0 support (#856) - Clustering: + - Automatic cluster node ID generation and management. - Unified cluster management (#960) - Outbound role (#1692) - Directory: - Domain aliases (#583) - E-mail alias descriptions and disable alias (#506) -- Account archiving and undelete features (#2767) (Enterprise feature). - Sieve: Allow deactivating scripts without deleting them (#1251). - Tracing: Enable events only mode (#2276) +- Enterprise features: + - Masked email addresses for enhanced privacy. + - Account archiving and undelete features (#2767). + - Per-domain directory backends. ## Changed - Replaced REST API with JMAP API (#2262 #959 #1480) @@ -62,8 +66,19 @@ This version includes **multiple breaking changes**. If you are upgrading from v - MTA - Relay to IP addresses (#838) - Duplicate delivery inverted check + - SASL challenge responses include invalid `Go ahead` text +- JMAP: + - Fix `inMailboxOtherThan` query logic. + - Fix `hasAttachment` search field (#2778) +- IMAP: Increment argument max length to `8000` bytes +- WebDAV: Return `304` `NOT_MODIFIED` on `If-None-Match`. - Configuration: Prefix parsing issues (#2495) -- OIDC: JWKS Exposes Symmetric Signing Key, Enabling ID Token Forgery +- OIDC: JWKS Exposes Symmetric Signing Key +- SQLite: Fix thread pool exhaustion. +- PostgreSQL: Use clean recycling method on connection pool +- Meilisearch: Make `id` sorteable. +- Spam filter: Skip invalid messages during training. +- Calendar: Include minutes in localized invite templates (#2828) ## [0.15.5] - 2026-02-14 diff --git a/Cargo.lock b/Cargo.lock index 4978b13c..87714271 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,6 +237,18 @@ dependencies = [ "futures-core", ] +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-lock" version = "3.4.2" @@ -401,7 +413,7 @@ dependencies = [ "pin-project", "quick-xml 0.31.0", "rand 0.8.5", - "reqwest", + "reqwest 0.12.28", "rustc_version", "serde", "serde_json", @@ -1117,7 +1129,7 @@ dependencies = [ "rcgen 0.12.1", "regex", "registry", - "reqwest", + "reqwest 0.12.28", "ring", "rkyv", "rsa", @@ -1162,6 +1174,23 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1812,7 +1841,7 @@ dependencies = [ "pwhash", "regex", "registry", - "reqwest", + "reqwest 0.12.28", "rkyv", "rustls 0.23.36", "rustls-pki-types", @@ -1873,16 +1902,15 @@ dependencies = [ [[package]] name = "dns-update" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fb12339544bc327ee50abbb4f522e3367216f5ccf6240c01ca4018d17a88ea2" +version = "0.2.0" dependencies = [ "hickory-client", - "reqwest", + "reqwest 0.13.2", + "ring", + "rustls 0.23.36", "serde", "serde_json", "serde_urlencoded", - "sha1", "tokio", ] @@ -2665,25 +2693,6 @@ dependencies = [ "utils", ] -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.13.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "h2" version = "0.4.13" @@ -2787,20 +2796,19 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hickory-client" -version = "0.24.4" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "156579a5cd8d1fc6f0df87cc21b6ee870db978a163a1ba484acd98a4eff5a6de" +checksum = "c466cd63a4217d5b2b8e32f23f58312741ce96e3c84bf7438677d2baff0fc555" dependencies = [ "cfg-if", "data-encoding", "futures-channel", "futures-util", - "hickory-proto 0.24.4", + "hickory-proto 0.25.2", "once_cell", "radix_trie", - "rand 0.8.5", - "rustls 0.21.12", - "thiserror 1.0.69", + "rand 0.9.2", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -2812,6 +2820,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" dependencies = [ "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.8.5", + "thiserror 1.0.69", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "bitflags", "bytes", "cfg-if", "data-encoding", @@ -2819,19 +2852,20 @@ dependencies = [ "futures-channel", "futures-io", "futures-util", - "h2 0.3.27", - "http 0.2.12", + "h2", + "http 1.4.0", "idna", "ipnet", "once_cell", - "rand 0.8.5", + "rand 0.9.2", "ring", - "rustls 0.21.12", - "rustls-pemfile 1.0.4", - "thiserror 1.0.69", + "rustls 0.23.36", + "rustls-pki-types", + "thiserror 2.0.18", + "time", "tinyvec", "tokio", - "tokio-rustls 0.24.1", + "tokio-rustls 0.26.4", "tracing", "url", ] @@ -3128,7 +3162,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", + "h2", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -3185,6 +3219,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -3645,7 +3695,7 @@ dependencies = [ "pkcs8", "rand 0.9.2", "registry", - "reqwest", + "reqwest 0.12.28", "rkyv", "rsa", "serde", @@ -3679,7 +3729,7 @@ dependencies = [ "futures-util", "maybe-async", "parking_lot", - "reqwest", + "reqwest 0.12.28", "rustls 0.23.36", "rustls-pki-types", "serde", @@ -4487,6 +4537,23 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework 3.6.0", + "security-framework-sys", + "tempfile", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -4839,7 +4906,7 @@ dependencies = [ "bytes", "http 1.4.0", "opentelemetry", - "reqwest", + "reqwest 0.12.28", "tracing", ] @@ -4856,7 +4923,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest", + "reqwest 0.12.28", "thiserror 2.0.18", "tokio", "tonic", @@ -6115,7 +6182,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.4.13", + "h2", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -6148,6 +6215,41 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "h2", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.7", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -7022,7 +7124,7 @@ dependencies = [ "memory-stats", "p256", "registry", - "reqwest", + "reqwest 0.12.28", "rsa", "serde", "serde_json", @@ -7261,7 +7363,7 @@ dependencies = [ "rayon", "regex", "registry", - "reqwest", + "reqwest 0.12.28", "rkyv", "rustls 0.23.36", "rustls-pemfile 2.2.0", @@ -7353,7 +7455,7 @@ dependencies = [ "nlp", "psl", "registry", - "reqwest", + "reqwest 0.12.28", "rkyv", "serde", "sha1", @@ -7502,7 +7604,7 @@ dependencies = [ "redis", "regex", "registry", - "reqwest", + "reqwest 0.12.28", "ring", "rkyv", "roaring", @@ -7612,6 +7714,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand 2.3.0", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "term" version = "1.2.1" @@ -7678,7 +7793,7 @@ dependencies = [ "quick-xml 0.38.4", "rayon", "registry", - "reqwest", + "reqwest 0.12.28", "ring", "rkyv", "rustls 0.23.36", @@ -7867,6 +7982,16 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-postgres" version = "0.7.16" @@ -8090,13 +8215,18 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", "futures-util", "http 1.4.0", "http-body 1.0.1", + "http-body-util", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower 0.5.3", "tower-layer", "tower-service", @@ -8201,7 +8331,7 @@ dependencies = [ "mail-auth", "mail-parser", "parking_lot", - "reqwest", + "reqwest 0.12.28", "rkyv", "rtrb", "serde", @@ -8483,7 +8613,7 @@ dependencies = [ "rand 0.9.2", "rcgen 0.14.7", "regex", - "reqwest", + "reqwest 0.12.28", "ring", "rkyv", "rustls 0.23.36", diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 8bd47eba..180384d4 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -21,7 +21,7 @@ mail-builder = { version = "0.4" } mail-auth = { path = "/Users/me/code/mail-auth" } mail-send = { version = "0.5", default-features = false, features = ["cram-md5", "ring", "tls12"] } smtp-proto = { version = "0.2", features = ["rkyv"] } -dns-update = { version = "0.1.5" } +dns-update = { path = "/Users/me/code/dns-update" } calcard = { version = "0.3", features = ["rkyv"] } ahash = { version = "0.8.2", features = ["serde"] } parking_lot = "0.12.1" diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index 08fc3a1d..9a6fa733 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -4,6 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use std::io::Cursor; + use self::{mailstore::jmap::JmapConfig, smtp::SmtpConfig, storage::Storage}; use crate::{ Core, Network, @@ -96,34 +98,40 @@ impl Core { } pub fn build_rsa_keypair(pem: &str) -> Result { - match rustls_pemfile::read_one(&mut pem.as_bytes()) { - Ok(Some(rustls_pemfile::Item::Pkcs1Key(key))) => { - RsaKeyPair::from_der(key.secret_pkcs1_der()) - .map_err(|err| format!("Failed to parse PKCS1 RSA key: {err}")) + for item in rustls_pemfile::read_all(&mut Cursor::new(pem)) { + match item.map_err(|err| format!("Failed to read private key PEM: {err}"))? { + rustls_pemfile::Item::Pkcs1Key(key) => { + return RsaKeyPair::from_der(key.secret_pkcs1_der()) + .map_err(|err| format!("Failed to parse PKCS1 RSA key: {err}")); + } + rustls_pemfile::Item::Pkcs8Key(key) => { + return RsaKeyPair::from_pkcs8(key.secret_pkcs8_der()) + .map_err(|err| format!("Failed to parse PKCS8 RSA key: {err}")); + } + _ => continue, // Skip certificates, DH params, etc. } - Ok(Some(rustls_pemfile::Item::Pkcs8Key(key))) => { - RsaKeyPair::from_pkcs8(key.secret_pkcs8_der()) - .map_err(|err| format!("Failed to parse PKCS8 RSA key: {err}")) - } - Err(err) => Err(format!("Failed to read PEM: {err}")), - Ok(Some(key)) => Err(format!("Unsupported key type: {key:?}")), - Ok(None) => Err("No RSA key found in PEM".to_string()), } + + Err("No RSA key found in PEM".to_string()) } pub fn build_ecdsa_pem( alg: &'static ring::signature::EcdsaSigningAlgorithm, pem: &str, ) -> Result { - match rustls_pemfile::read_one(&mut pem.as_bytes()) { - Ok(Some(rustls_pemfile::Item::Pkcs8Key(key))) => EcdsaKeyPair::from_pkcs8( - alg, - key.secret_pkcs8_der(), - &ring::rand::SystemRandom::new(), - ) - .map_err(|err| format!("Failed to parse PKCS8 ECDSA key: {err}")), - Err(err) => Err(format!("Failed to read PEM: {err}")), - Ok(Some(key)) => Err(format!("Unsupported key type: {key:?}")), - Ok(None) => Err("No ECDSA key found in PEM".to_string()), + for item in rustls_pemfile::read_all(&mut Cursor::new(pem)) { + match item.map_err(|err| format!("Failed to read private key PEM: {err}"))? { + rustls_pemfile::Item::Pkcs8Key(key) => { + return EcdsaKeyPair::from_pkcs8( + alg, + key.secret_pkcs8_der(), + &ring::rand::SystemRandom::new(), + ) + .map_err(|err| format!("Failed to parse PKCS8 ECDSA key: {err}")); + } + _ => continue, // Skip certificates, DH params, etc. + } } + + Err("No ECDSA key found in PEM".to_string()) } diff --git a/crates/common/src/config/server/tls.rs b/crates/common/src/config/server/tls.rs index f33e126d..3ac8dd21 100644 --- a/crates/common/src/config/server/tls.rs +++ b/crates/common/src/config/server/tls.rs @@ -4,62 +4,41 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::{Server, network::acme::AcmeProvider}; +use crate::Server; use ahash::{AHashMap, AHashSet}; use dns_update::{ Algorithm, DnsUpdater, TsigAlgorithm, + dnssec::{ + self, SigningKey, + crypto::{EcdsaSigningKey, Ed25519SigningKey}, + }, providers::{ovh::OvhEndpoint, rfc2136::DnsAddress}, }; -use hickory_proto::rr::dnssec::KeyPair; use rcgen::generate_simple_self_signed; use registry::schema::{ enums, - structs::{self, Certificate, DnsServer, SystemSettings}, + prelude::Object, + structs::{Certificate, DnsServer, SystemSettings}, }; -use ring::signature::{EcdsaKeyPair, Ed25519KeyPair}; use rustls::{ SupportedProtocolVersion, crypto::ring::sign::any_supported_type, sign::CertifiedKey, version::{TLS12, TLS13}, }; -use rustls_pemfile::{Item, certs, read_one}; -use rustls_pki_types::PrivateKeyDer; -use std::{ - io::Cursor, - net::{Ipv4Addr, Ipv6Addr, SocketAddr}, - sync::Arc, +use rustls_pemfile::{Item, certs, read_all}; +use rustls_pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; +use std::{io::Cursor, net::SocketAddr, sync::Arc}; +use store::{ + registry::{bootstrap::Bootstrap, write::RegistryWrite}, + write::now, }; -use store::registry::bootstrap::Bootstrap; use trc::AddContext; -use x509_parser::{ - certificate::X509Certificate, - der_parser::asn1_rs::FromDer, - extensions::{GeneralName, ParsedExtension}, -}; pub static TLS13_VERSION: &[&SupportedProtocolVersion] = &[&TLS13]; pub static TLS12_VERSION: &[&SupportedProtocolVersion] = &[&TLS12]; impl Server { - pub async fn build_acme_provider(&self, id: u64) -> trc::Result { - if let Some(server) = self - .registry() - .object::(id.into()) - .await - .caused_by(trc::location!())? - { - Ok(AcmeProvider::new(server)) - } else { - trc::bail!( - trc::AcmeEvent::Error - .into_err() - .id(id.to_string()) - .details("ACME provider not found") - ) - } - } - pub async fn build_dns_updater(&self, id: u64) -> trc::Result { let Some(server) = self .registry() @@ -113,94 +92,72 @@ impl Server { enums::TsigAlgorithm::HmacSha512256 => TsigAlgorithm::HmacSha512_256, }, ), - DnsServer::Sig0(server) => DnsUpdater::new_rfc2136_sig0( - match server.protocol { - enums::IpProtocol::Udp => DnsAddress::Tcp(SocketAddr::new( - server.host.into_inner(), - server.port as u16, - )), - enums::IpProtocol::Tcp => DnsAddress::Udp(SocketAddr::new( - server.host.into_inner(), - server.port as u16, - )), - }, - server.signer_name, - match server.sig0_algorithm { - enums::Sig0Algorithm::EcdsaP256Sha256 => KeyPair::ECDSA( - EcdsaKeyPair::from_pkcs8( - &ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING, - server - .key - .secret() - .await - .map_err(|err| { - trc::DnsEvent::BuildError - .reason(err) - .details("Failed to obtain key secret") - .id(id.to_string()) - })? - .as_bytes(), - &ring::rand::SystemRandom::new(), - ) - .map_err(|err| { + DnsServer::Sig0(server) => { + let key_bytes = server.key.secret().await.map_err(|err| { + trc::DnsEvent::BuildError + .reason(err) + .details("Failed to obtain key secret") + .id(id.to_string()) + })?; + + let pem_parsed = pem::parse(key_bytes.as_bytes()).map_err(|err| { + trc::DnsEvent::BuildError + .reason(err) + .details("Failed to parse PEM key") + .id(id.to_string()) + })?; + let pkcs8_der = PrivatePkcs8KeyDer::from(pem_parsed.contents()); + + let signing_key: Box = match server.sig0_algorithm { + enums::Sig0Algorithm::EcdsaP256Sha256 => Box::new( + EcdsaSigningKey::from_pkcs8(&pkcs8_der, dnssec::Algorithm::ECDSAP256SHA256) + .map_err(|err| { + trc::DnsEvent::BuildError + .reason(err) + .details("Failed to build ECDSA P-256 signing key") + .id(id.to_string()) + })?, + ), + enums::Sig0Algorithm::EcdsaP384Sha384 => Box::new( + EcdsaSigningKey::from_pkcs8(&pkcs8_der, dnssec::Algorithm::ECDSAP384SHA384) + .map_err(|err| { + trc::DnsEvent::BuildError + .reason(err) + .details("Failed to build ECDSA P-384 signing key") + .id(id.to_string()) + })?, + ), + enums::Sig0Algorithm::Ed25519 => { + Box::new(Ed25519SigningKey::from_pkcs8(&pkcs8_der).map_err(|err| { trc::DnsEvent::BuildError .reason(err) - .details("Failed to build ECDSA P-256 key pair") + .details("Failed to build Ed25519 signing key") .id(id.to_string()) - })?, - ), - enums::Sig0Algorithm::EcdsaP384Sha384 => KeyPair::ECDSA( - EcdsaKeyPair::from_pkcs8( - &ring::signature::ECDSA_P384_SHA384_ASN1_SIGNING, - server - .key - .secret() - .await - .map_err(|err| { - trc::DnsEvent::BuildError - .reason(err) - .details("Failed to obtain key secret") - .id(id.to_string()) - })? - .as_bytes(), - &ring::rand::SystemRandom::new(), - ) - .map_err(|err| { - trc::DnsEvent::BuildError - .reason(err) - .details("Failed to build ECDSA P-384 key pair") - .id(id.to_string()) - })?, - ), - enums::Sig0Algorithm::Ed25519 => KeyPair::ED25519( - Ed25519KeyPair::from_pkcs8( - server - .key - .secret() - .await - .map_err(|err| { - trc::DnsEvent::BuildError - .reason(err) - .details("Failed to obtain key secret") - .id(id.to_string()) - })? - .as_bytes(), - ) - .map_err(|err| { - trc::DnsEvent::BuildError - .reason(err) - .details("Failed to build Ed25519 key pair") - .id(id.to_string()) - })?, - ), - }, - server.public_key, - match server.sig0_algorithm { - enums::Sig0Algorithm::EcdsaP256Sha256 => Algorithm::ECDSAP256SHA256, - enums::Sig0Algorithm::EcdsaP384Sha384 => Algorithm::ECDSAP384SHA384, - enums::Sig0Algorithm::Ed25519 => Algorithm::ED25519, - }, - ), + })?) + } + }; + + DnsUpdater::new_rfc2136_sig0( + match server.protocol { + enums::IpProtocol::Udp => DnsAddress::Tcp(SocketAddr::new( + server.host.into_inner(), + server.port as u16, + )), + enums::IpProtocol::Tcp => DnsAddress::Udp(SocketAddr::new( + server.host.into_inner(), + server.port as u16, + )), + }, + server.signer_name, + signing_key, + server.public_key, + match server.sig0_algorithm { + enums::Sig0Algorithm::EcdsaP256Sha256 => Algorithm::ECDSAP256SHA256, + enums::Sig0Algorithm::EcdsaP384Sha384 => Algorithm::ECDSAP384SHA384, + enums::Sig0Algorithm::Ed25519 => Algorithm::ED25519, + }, + ) + } DnsServer::Cloudflare(server) => DnsUpdater::new_cloudflare( server.secret.secret().await.map_err(|err| { trc::DnsEvent::BuildError @@ -271,8 +228,33 @@ pub(crate) async fn parse_certificates( let system = bp.setting_infallible::().await; // Parse certificates + let now = now() as i64; + let mut certs_expired = Vec::new(); + let mut certs_expirations = AHashMap::new(); for cert_obj in bp.list_infallible::().await { - let secret = match cert_obj.object.private_key.secret().await { + let not_valid_after = cert_obj.object.not_valid_after.timestamp(); + let not_valid_before = cert_obj.object.not_valid_before.timestamp(); + + if not_valid_after <= now { + certs_expired.push(( + cert_obj.id, + cert_obj + .object + .subject_alternative_names + .clone() + .into_inner(), + Object { + inner: cert_obj.object.into(), + revision: cert_obj.revision, + }, + )); + continue; + } else if not_valid_before > now { + continue; // Skip certificates that are not yet valid + } + + let mut cert = cert_obj.object; + let secret = match cert.private_key.secret().await { Ok(secret) => secret.into_owned().into_bytes(), Err(err) => { bp.build_error( @@ -282,7 +264,7 @@ pub(crate) async fn parse_certificates( continue; } }; - let public = match cert_obj.object.certificate.value().await { + let public = match cert.certificate.value().await { Ok(value) => value.into_owned().into_bytes(), Err(err) => { bp.build_error( @@ -293,83 +275,41 @@ pub(crate) async fn parse_certificates( } }; + // Add default certificate + if system + .default_certificate_id + .as_ref() + .is_some_and(|id| *id == cert_obj.id.id()) + { + cert.subject_alternative_names + .push_unchecked("*".to_string()); + } + + // Ensure that the most up-to-date certificate is used + cert.subject_alternative_names.inner_mut().retain(|name| { + if certs_expirations + .get(name) + .is_none_or(|expires| *expires < not_valid_after) + { + certs_expirations.insert(name.clone(), not_valid_after); + true + } else { + false + } + }); + match build_certified_key(public, secret) { - Ok(cert) => { - match cert - .end_entity_cert() - .map_err(|err| format!("Failed to obtain end entity cert: {err}")) - .and_then(|cert| { - X509Certificate::from_der(cert.as_ref()) - .map_err(|err| format!("Failed to parse end entity cert: {err}")) - }) { - Ok((_, parsed)) => { - // Add CNs and SANs to the list of names - let mut names: AHashSet> = AHashSet::new(); - for name in parsed.subject().iter_common_name() { - if let Ok(name) = name.as_str() { - names.insert(name.into()); - } - } - for ext in parsed.extensions() { - if let ParsedExtension::SubjectAlternativeName(san) = - ext.parsed_extension() - { - for name in &san.general_names { - let name: Box = match name { - GeneralName::DNSName(name) => (*name).into(), - GeneralName::IPAddress(ip) => match ip.len() { - 4 => Ipv4Addr::from(<[u8; 4]>::try_from(*ip).unwrap()) - .to_string() - .into(), - 16 => { - Ipv6Addr::from(<[u8; 16]>::try_from(*ip).unwrap()) - .to_string() - .into() - } - _ => continue, - }, - _ => { - continue; - } - }; - names.insert(name); - } - } - } - - // Add custom SNIs - names.extend( - cert_obj - .object - .subject_alternative_names - .into_iter() - .map(Into::into), - ); - - // Add domain names - subject_names.extend(names.iter().cloned()); - - // Add certificates - let cert = Arc::new(cert); - for name in names { - certificates.insert( - name.strip_prefix("*.").map(Into::into).unwrap_or(name), - cert.clone(), - ); - } - - // Add default certificate - if system - .default_certificate_id - .as_ref() - .is_some_and(|id| *id == cert_obj.id.id()) - { - certificates.insert("*".into(), cert.clone()); - } - } - Err(err) => { - bp.build_error(cert_obj.id, format!("Invalid certificate: {err}")); - } + Ok(key) => { + // Add certificates + let key = Arc::new(key); + for name in cert.subject_alternative_names.into_inner() { + subject_names.insert(name.as_str().into()); + certificates.insert( + name.strip_prefix("*.") + .map(Into::into) + .unwrap_or_else(|| name.into_boxed_str()), + key.clone(), + ); } } Err(err) => { @@ -377,33 +317,66 @@ pub(crate) async fn parse_certificates( } } } + + // Remove expired certificates + if !certs_expired.is_empty() { + for (id, sans, object) in certs_expired { + if let Err(err) = bp + .registry + .write(RegistryWrite::delete_object(id, &object)) + .await + { + trc::error!( + err.details("Failed to delete expired TLS certificate from registry.") + .caused_by(trc::location!()) + ); + } else { + trc::event!( + Tls(trc::TlsEvent::ExpiredCertificateRemoved), + Details = sans + ); + } + } + } } -pub(crate) fn build_certified_key(cert: Vec, pk: Vec) -> Result { +pub(crate) fn build_certified_key( + cert: Vec, + pk_bytes: Vec, +) -> Result { + let mut pk = None; + for item in read_all(&mut Cursor::new(pk_bytes)) { + match item.map_err(|err| format!("Failed to read private key PEM: {err}"))? { + Item::Pkcs8Key(key) => { + pk = Some(PrivateKeyDer::Pkcs8(key)); + break; + } + Item::Pkcs1Key(key) => { + pk = Some(PrivateKeyDer::Pkcs1(key)); + break; + } + Item::Sec1Key(key) => { + pk = Some(PrivateKeyDer::Sec1(key)); + break; + } + _ => continue, // Skip certificates, DH params, etc. + } + } + let pk = pk.ok_or_else(|| "No private keys found.".to_string())?; let cert = certs(&mut Cursor::new(cert)) .collect::, _>>() .map_err(|err| format!("Failed to read certificates: {err}"))?; - if cert.is_empty() { - return Err("No certificates found.".to_string()); - } - let pk = match read_one(&mut Cursor::new(pk)) - .map_err(|err| format!("Failed to read private keys.: {err}",))? - .into_iter() - .next() - { - Some(Item::Pkcs8Key(key)) => PrivateKeyDer::Pkcs8(key), - Some(Item::Pkcs1Key(key)) => PrivateKeyDer::Pkcs1(key), - Some(Item::Sec1Key(key)) => PrivateKeyDer::Sec1(key), - Some(_) => return Err("Unsupported private keys found.".to_string()), - None => return Err("No private keys found.".to_string()), - }; - Ok(CertifiedKey { - cert, - key: any_supported_type(&pk) - .map_err(|err| format!("Failed to sign certificate: {err}",))?, - ocsp: None, - }) + if !cert.is_empty() { + Ok(CertifiedKey { + cert, + key: any_supported_type(&pk) + .map_err(|err| format!("Failed to sign certificate: {err}",))?, + ocsp: None, + }) + } else { + Err("No certificates found.".to_string()) + } } pub(crate) fn build_self_signed_cert( diff --git a/crates/common/src/config/smtp/auth.rs b/crates/common/src/config/smtp/auth.rs index cf765b4d..69844191 100644 --- a/crates/common/src/config/smtp/auth.rs +++ b/crates/common/src/config/smtp/auth.rs @@ -139,7 +139,11 @@ impl DkimSigner { match signature { DkimSignature::Dkim1Ed25519Sha256(signature) => { - let private_key = signature.private_key.pem().await?; + let private_key = signature + .private_key + .secret() + .await + .map_err(|err| trc::DkimEvent::BuildError.reason(err))?; let private_key = simple_pem_parse(&private_key).ok_or_else(|| { trc::DkimEvent::BuildError .reason("Failed to parse ED25519 private key PEM") @@ -157,7 +161,11 @@ impl DkimSigner { ))) } DkimSignature::Dkim1RsaSha256(signature) => { - let private_key = signature.private_key.pem().await?; + let private_key = signature + .private_key + .secret() + .await + .map_err(|err| trc::DkimEvent::BuildError.reason(err))?; let key = rsa_key_parse(private_key.as_bytes())?; Ok(DkimSigner::RsaSha256(build_dkim1_signer( diff --git a/crates/common/src/ipc.rs b/crates/common/src/ipc.rs index a1b9a0ca..5e15d7af 100644 --- a/crates/common/src/ipc.rs +++ b/crates/common/src/ipc.rs @@ -80,7 +80,7 @@ pub enum BroadcastEvent { MtaQueueStatus { is_running: bool }, } -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub enum RegistryChange { Insert(ObjectId), Delete(ObjectId), diff --git a/crates/common/src/network/acme/account.rs b/crates/common/src/network/acme/account.rs new file mode 100644 index 00000000..c0294b30 --- /dev/null +++ b/crates/common/src/network/acme/account.rs @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::jose::{Body, eab_sign, sign}; +use crate::network::acme::http::{get_header, https}; +use crate::network::acme::{AcmeError, AcmeResult, Directory}; +use base64::Engine; +use base64::engine::general_purpose::{self, URL_SAFE_NO_PAD}; +use registry::schema::structs::AcmeProvider; +use reqwest::Method; +use ring::rand::SystemRandom; +use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, EcdsaSigningAlgorithm}; +use utils::sanitize_email; + +static ALG: &EcdsaSigningAlgorithm = &ECDSA_P256_SHA256_FIXED_SIGNING; + +#[derive(Clone)] +pub struct EabSettings { + pub kid: String, + pub hmac_key: Vec, +} + +#[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, +} + +pub async fn acme_create_account( + provider: &mut AcmeProvider, + eab: Option, +) -> AcmeResult<()> { + if provider.contact.is_empty() { + return Err(AcmeError::Invalid( + "At least one contact email is required".to_string(), + )); + } + + for contact in provider.contact.iter_mut() { + let email = sanitize_email(contact.trim().strip_prefix("mailto:").unwrap_or(contact)) + .ok_or_else(|| AcmeError::Invalid(format!("Invalid contact email: {}", contact)))?; + *contact = format!("mailto:{}", email); + } + + let directory = Directory::discover(&provider.directory).await?; + let account_key = EcdsaKeyPair::generate_pkcs8(ALG, &SystemRandom::new()).unwrap(); + let key_pair = EcdsaKeyPair::from_pkcs8(ALG, account_key.as_ref(), &SystemRandom::new()) + .map_err(|err| AcmeError::Crypto(format!("Failed to create ECDSA key pair: {}", err)))?; + let eab = if let Some(eab) = &eab { + eab_sign(&key_pair, &eab.kid, &eab.hmac_key, &directory.new_account)?.into() + } else { + None + }; + + let payload = serde_json::to_string(&NewAccountPayload { + tos_agreed: true, + contact: provider.contact.as_slice(), + eab, + }) + .unwrap_or_default(); + let body = sign( + &key_pair, + None, + directory.nonce().await?, + &directory.new_account, + &payload, + )?; + + provider.account_uri = get_header( + &https(&directory.new_account, Method::POST, Some(body)).await?, + "Location", + )?; + provider.account_key = URL_SAFE_NO_PAD.encode(account_key.as_ref()); + + Ok(()) +} + +impl EabSettings { + pub fn new(kid: impl Into, hmac_key: impl AsRef<[u8]>) -> AcmeResult { + let key = general_purpose::URL_SAFE_NO_PAD + .decode(hmac_key.as_ref()) + .map_err(|err| AcmeError::Invalid(format!("Failed to decode EAB HMAC key: {}", err)))?; + Ok(Self { + kid: kid.into(), + hmac_key: key, + }) + } +} diff --git a/crates/common/src/network/acme/cache.rs b/crates/common/src/network/acme/cache.rs deleted file mode 100644 index e7cb8b7a..00000000 --- a/crates/common/src/network/acme/cache.rs +++ /dev/null @@ -1,126 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use super::AcmeProvider; -use crate::Server; -use trc::AddContext; - -impl Server { - pub(crate) async fn load_cert(&self, provider: &AcmeProvider) -> trc::Result>> { - self.read_if_exists(provider, "cert", provider.domains.as_slice()) - .await - .add_context(|err| { - err.caused_by(trc::location!()) - .details("Failed to load certificates") - }) - } - - pub(crate) async fn store_cert(&self, provider: &AcmeProvider, cert: &[u8]) -> trc::Result<()> { - self.write(provider, "cert", provider.domains.as_slice(), cert) - .await - .add_context(|err| { - err.caused_by(trc::location!()) - .details("Failed to store certificate") - }) - } - - pub(crate) async fn load_account( - &self, - provider: &AcmeProvider, - ) -> trc::Result>> { - self.read_if_exists(provider, "account-key", provider.contact.as_slice()) - .await - .add_context(|err| { - err.caused_by(trc::location!()) - .details("Failed to load account") - }) - } - - pub(crate) async fn store_account( - &self, - provider: &AcmeProvider, - account: &[u8], - ) -> trc::Result<()> { - self.write( - provider, - "account-key", - provider.contact.as_slice(), - account, - ) - .await - .add_context(|err| { - err.caused_by(trc::location!()) - .details("Failed to store account") - }) - } - - async fn read_if_exists( - &self, - provider: &AcmeProvider, - class: &str, - items: &[String], - ) -> trc::Result>> { - todo!() - /*if let Some(content) = self - .core - .storage - .config - .get(self.build_key(provider, class, items)) - .await? - { - URL_SAFE_NO_PAD - .decode(content.as_bytes()) - .map_err(|err| { - trc::EventType::Acme(trc::AcmeEvent::Error) - .caused_by(trc::location!()) - .reason(err) - .details("failed to decode certificate") - }) - .map(Some) - } else { - Ok(None) - }*/ - } - - async fn write( - &self, - provider: &AcmeProvider, - class: &str, - items: &[String], - contents: impl AsRef<[u8]>, - ) -> trc::Result<()> { - todo!() - /*self.core - .storage - .config - .set( - [ConfigKey { - key: self.build_key(provider, class, items), - value: URL_SAFE_NO_PAD.encode(contents.as_ref()), - }], - true, - ) - .await*/ - } - - fn build_key(&self, provider: &AcmeProvider, class: &str, _: &[String]) -> String { - /*let mut ctx = Context::new(&SHA512); - for el in items { - ctx.update(el.as_ref()); - ctx.update(&[0]) - } - ctx.update(provider.directory_url.as_bytes()); - - format!( - "certificate.acme-{}-{}.{}", - provider.id, - URL_SAFE_NO_PAD.encode(ctx.finish()), - class - )*/ - - format!("acme.{}.{}", provider.id, class) - } -} diff --git a/crates/common/src/network/acme/directory.rs b/crates/common/src/network/acme/directory.rs index 21640cd6..d78e43e8 100644 --- a/crates/common/src/network/acme/directory.rs +++ b/crates/common/src/network/acme/directory.rs @@ -1,104 +1,70 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + // Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0. -use super::AcmeProvider; +use std::time::Duration; + use super::jose::{ - Body, eab_sign, key_authorization, key_authorization_sha256, key_authorization_sha256_base64, - sign, + key_authorization, key_authorization_sha256, key_authorization_sha256_base64, sign, +}; +use crate::network::acme::http::{get_header, https, parse_retry_after}; +use crate::network::acme::{ + AcmeError, AcmeResult, Auth, AuthStatus, Challenge, ChallengeType, Directory, Identifier, + Order, SerializedCert, }; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use hyper::header::USER_AGENT; use rcgen::{Certificate, CustomExtension, PKCS_ECDSA_P256_SHA256}; -use reqwest::header::CONTENT_TYPE; -use reqwest::{Method, Response}; +use registry::schema::structs::AcmeProvider; +use reqwest::Method; use ring::rand::SystemRandom; use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, EcdsaSigningAlgorithm}; -use serde::Deserialize; -use std::time::Duration; +use serde::de::DeserializeOwned; +use serde_json::json; use store::Serialize; use store::write::Archiver; -use trc::AddContext; -use trc::event::conv::AssertSuccess; -pub const LETS_ENCRYPT_STAGING_DIRECTORY: &str = - "https://acme-staging-v02.api.letsencrypt.org/directory"; -pub const LETS_ENCRYPT_PRODUCTION_DIRECTORY: &str = - "https://acme-v02.api.letsencrypt.org/directory"; pub const ACME_TLS_ALPN_NAME: &[u8] = b"acme-tls/1"; #[derive(Debug)] -pub struct Account { +pub struct AcmeRequestBuilder { pub key_pair: EcdsaKeyPair, pub directory: Directory, pub kid: String, + pub challenge: ChallengeType, } -#[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, +pub struct AcmeResponse { + pub location: L, + pub body: B, + pub retry_after: Option, } static ALG: &EcdsaSigningAlgorithm = &ECDSA_P256_SHA256_FIXED_SIGNING; -impl Account { - pub fn generate_key_pair() -> Vec { - EcdsaKeyPair::generate_pkcs8(ALG, &SystemRandom::new()) - .unwrap() - .as_ref() - .to_vec() - } - - pub async fn create(directory: Directory, provider: &AcmeProvider) -> trc::Result { - Self::create_with_keypair(directory, provider).await - } - - pub async fn create_with_keypair( - directory: Directory, - provider: &AcmeProvider, - ) -> trc::Result { +impl AcmeRequestBuilder { + pub async fn new(provider: AcmeProvider) -> AcmeResult { + let directory = Directory::discover(&provider.directory).await?; let key_pair = EcdsaKeyPair::from_pkcs8( ALG, - provider.account_key.load().as_slice(), + &URL_SAFE_NO_PAD + .decode(&provider.account_key) + .map_err(|err| { + AcmeError::Crypto(format!("Failed to decode account key: {}", err)) + })?, &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 - }; + .map_err(|err| AcmeError::Crypto(format!("Failed to create ECDSA key pair: {}", err)))?; - let payload = serde_json::to_string(&NewAccountPayload { - tos_agreed: true, - contact: &provider.contact, - eab, - }) - .unwrap_or_default(); - - let body = sign( - &key_pair, - None, - directory.nonce().await?, - &directory.new_account, - &payload, - )?; - let response = https(&directory.new_account, Method::POST, Some(body)).await?; - let kid = get_header(&response, "Location")?; - Ok(Account { + Ok(Self { key_pair, - kid, directory, + kid: provider.account_uri, + challenge: provider.class.into(), }) } @@ -106,7 +72,7 @@ impl Account { &self, url: impl AsRef, payload: &str, - ) -> trc::Result<(Option, String)> { + ) -> AcmeResult, String>> { let body = sign( &self.key_pair, Some(&self.kid), @@ -115,120 +81,104 @@ impl Account { payload, )?; let response = https(url.as_ref(), Method::POST, Some(body)).await?; - let location = get_header(&response, "Location").ok(); - let body = response - .text() - .await - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_http_error(err))?; - Ok((location, body)) + + Ok(AcmeResponse { + location: get_header(&response, "Location").ok(), + retry_after: parse_retry_after(&response), + body: response.text().await?, + }) } - pub async fn new_order(&self, domains: Vec) -> trc::Result<(String, Order)> { + pub async fn new_order(&self, domains: Vec) -> AcmeResult> { let domains: Vec = domains.into_iter().map(Identifier::Dns).collect(); - let payload = format!( - "{{\"identifiers\":{}}}", - serde_json::to_string(&domains) - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err))? - ); + let payload = json!({ + "identifiers": domains, + }) + .to_string(); let response = self.request(&self.directory.new_order, &payload).await?; - let url = response.0.ok_or( - trc::EventType::Acme(trc::AcmeEvent::Error) - .caused_by(trc::location!()) - .details("Missing header") - .ctx(trc::Key::Id, "Location"), - )?; - let order = serde_json::from_str(&response.1) - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err))?; - Ok((url, order)) + Ok(AcmeResponse { + location: response.location.ok_or(AcmeError::Invalid(format!( + "Missing Location header in new order response from {}", + self.directory.new_order + )))?, + body: serde_json::from_str(&response.body).map_err(AcmeError::Json)?, + retry_after: response.retry_after, + }) } - pub async fn auth(&self, url: impl AsRef) -> trc::Result { - let response = self.request(url, "").await?; - serde_json::from_str(&response.1) - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err)) + pub async fn auth( + &self, + url: impl AsRef, + ) -> AcmeResult, Auth>> { + AcmeResponse::parse(self.request(url, "").await?) } - pub async fn challenge(&self, url: impl AsRef) -> trc::Result<()> { + pub async fn challenge(&self, url: impl AsRef) -> AcmeResult<()> { self.request(&url, "{}").await.map(|_| ()) } - pub async fn order(&self, url: impl AsRef) -> trc::Result { - let response = self.request(&url, "").await?; - serde_json::from_str(&response.1) - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err)) + pub async fn order( + &self, + url: impl AsRef, + ) -> AcmeResult, Order>> { + AcmeResponse::parse(self.request(&url, "").await?) } - pub async fn finalize(&self, url: impl AsRef, csr: Vec) -> trc::Result { + pub async fn finalize( + &self, + url: impl AsRef, + csr: Vec, + ) -> AcmeResult, Order>> { let payload = format!("{{\"csr\":\"{}\"}}", URL_SAFE_NO_PAD.encode(csr)); - let response = self.request(&url, &payload).await?; - serde_json::from_str(&response.1) - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err)) + AcmeResponse::parse(self.request(&url, &payload).await?) } - pub async fn certificate(&self, url: impl AsRef) -> trc::Result { - Ok(self.request(&url, "").await?.1) + pub async fn certificate(&self, url: impl AsRef) -> AcmeResult { + Ok(self.request(&url, "").await?.body) } - pub fn http_proof(&self, challenge: &Challenge) -> trc::Result> { + pub fn http_proof(&self, challenge: &Challenge) -> AcmeResult> { key_authorization(&self.key_pair, &challenge.token).map(|key| key.into_bytes()) } - pub fn dns_proof(&self, challenge: &Challenge) -> trc::Result { + pub fn dns_proof(&self, challenge: &Challenge) -> AcmeResult { key_authorization_sha256_base64(&self.key_pair, &challenge.token) } - pub fn tls_alpn_key(&self, challenge: &Challenge, domain: String) -> trc::Result> { + pub fn tls_alpn_key(&self, challenge: &Challenge, domain: String) -> AcmeResult> { let mut params = rcgen::CertificateParams::new(vec![domain]); let key_auth = key_authorization_sha256(&self.key_pair, &challenge.token)?; params.alg = &PKCS_ECDSA_P256_SHA256; params.custom_extensions = vec![CustomExtension::new_acme_identifier(key_auth.as_ref())]; let cert = Certificate::from_params(params).map_err(|err| { - trc::EventType::Acme(trc::AcmeEvent::Error) - .caused_by(trc::location!()) - .reason(err) + AcmeError::Crypto(format!( + "Failed to generate TLS-ALPN-01 certificate: {}", + err + )) })?; Archiver::new(SerializedCert { certificate: cert.serialize_der().map_err(|err| { - trc::EventType::Acme(trc::AcmeEvent::Error) - .caused_by(trc::location!()) - .reason(err) + AcmeError::Crypto(format!( + "Failed to serialize TLS-ALPN-01 certificate: {}", + err + )) })?, private_key: cert.serialize_private_key_der(), }) .untrusted() .serialize() + .map_err(|_| AcmeError::Crypto("Failed to serialize certificate".to_string())) } } -#[derive( - rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug, Clone, serde::Serialize, Deserialize, -)] -pub struct SerializedCert { - pub certificate: Vec, - pub private_key: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Directory { - pub new_nonce: String, - pub new_account: String, - pub new_order: String, -} - impl Directory { - pub async fn discover(url: impl AsRef) -> trc::Result { - serde_json::from_str( - &https(url, Method::GET, None) - .await? - .text() - .await - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_http_error(err))?, - ) - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err)) + pub async fn discover(url: impl AsRef) -> AcmeResult { + serde_json::from_str(&https(url, Method::GET, None).await?.text().await?) + .map_err(Into::into) } - pub async fn nonce(&self) -> trc::Result { + + pub async fn nonce(&self) -> AcmeResult { get_header( &https(&self.new_nonce.as_str(), Method::HEAD, None).await?, "replay-nonce", @@ -236,129 +186,29 @@ impl Directory { } } -#[derive(Debug, Deserialize, Eq, PartialEq, Clone, Copy)] -pub enum ChallengeType { - #[serde(rename = "http-01")] - Http01, - #[serde(rename = "dns-01")] - Dns01, - #[serde(rename = "tls-alpn-01")] - TlsAlpn01, - #[serde(other)] - Unknown, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Order { - #[serde(flatten)] - pub status: OrderStatus, - pub authorizations: Vec, - pub finalize: String, - pub error: Option, -} - -#[derive(Debug, Deserialize, Clone, PartialEq, Eq)] -#[serde(tag = "status", rename_all = "camelCase")] -pub enum OrderStatus { - Pending, - Ready, - Valid { certificate: String }, - Invalid, - Processing, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Auth { - pub status: AuthStatus, - pub identifier: Identifier, - pub challenges: Vec, - pub wildcard: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum AuthStatus { - Pending, - Valid, - Invalid, - Revoked, - Expired, - Deactivated, -} - -#[derive(Clone, Debug, serde::Serialize, Deserialize)] -#[serde(tag = "type", content = "value", rename_all = "camelCase")] -pub enum Identifier { - Dns(String), -} - -#[derive(Debug, Deserialize)] -pub struct Challenge { - #[serde(rename = "type")] - pub typ: ChallengeType, - pub url: String, - pub token: String, - pub error: Option, -} - -#[derive(Clone, Debug, serde::Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Problem { - #[serde(rename = "type")] - pub typ: Option, - pub detail: Option, -} - -#[allow(unused_mut)] -async fn https( - url: impl AsRef, - method: Method, - body: Option, -) -> trc::Result { - let url = url.as_ref(); - let mut builder = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .http1_only(); - - #[cfg(debug_assertions)] - { - builder = builder.danger_accept_invalid_certs( - url.starts_with("https://localhost") || url.starts_with("https://127.0.0.1"), - ); +impl AcmeResponse { + pub fn parse(input: AcmeResponse) -> AcmeResult> { + serde_json::from_str(&input.body) + .map_err(Into::into) + .map(|body| AcmeResponse { + location: input.location, + body, + retry_after: input.retry_after, + }) } - - let mut request = builder - .build() - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_http_error(err))? - .request(method, url) - .header(USER_AGENT, crate::USER_AGENT); - - if let Some(body) = body { - request = request - .header(CONTENT_TYPE, "application/jose+json") - .body(body); - } - - request - .send() - .await - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_http_error(err))? - .assert_success(trc::EventType::Acme(trc::AcmeEvent::Error)) - .await } -fn get_header(response: &Response, header: &'static str) -> trc::Result { - match response.headers().get_all(header).iter().next_back() { - Some(value) => Ok(value - .to_str() - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_http_str_error(err))? - .to_string()), - None => Err(trc::EventType::Acme(trc::AcmeEvent::Error) - .caused_by(trc::location!()) - .details("Missing header") - .ctx(trc::Key::Id, header)), +impl AcmeResponse { + pub fn assert_reasonable_retry_after(self) -> AcmeResult { + if let Some(retry_after) = self.retry_after + && retry_after > Duration::from_secs(10 * 60) + { + return Err(AcmeError::RetryAt { + time: Some(retry_after), + }); + } + + Ok(self) } } @@ -368,6 +218,7 @@ impl ChallengeType { Self::Http01 => "http-01", Self::Dns01 => "dns-01", Self::TlsAlpn01 => "tls-alpn-01", + Self::DnsPersist01 => "dns-persist-01", Self::Unknown => "unknown", } } diff --git a/crates/common/src/network/acme/http.rs b/crates/common/src/network/acme/http.rs new file mode 100644 index 00000000..2a573714 --- /dev/null +++ b/crates/common/src/network/acme/http.rs @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::network::acme::{AcmeError, AcmeResult}; +use chrono::{DateTime, Utc}; +use hyper::{ + Method, StatusCode, + header::{CONTENT_TYPE, USER_AGENT}, +}; +use reqwest::Response; +use std::time::Duration; + +#[allow(unused_mut)] +pub(crate) async fn https( + url: impl AsRef, + method: Method, + body: Option, +) -> AcmeResult { + let url = url.as_ref(); + let mut builder = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .http1_only(); + + #[cfg(debug_assertions)] + { + builder = builder.danger_accept_invalid_certs( + url.starts_with("https://localhost") || url.starts_with("https://127.0.0.1"), + ); + } + + let mut request = builder + .build()? + .request(method, url) + .header(USER_AGENT, crate::USER_AGENT); + + if let Some(body) = body { + request = request + .header(CONTENT_TYPE, "application/jose+json") + .body(body); + } + + let response = request.send().await?; + if response.status().is_success() { + Ok(response) + } else if matches!( + response.status(), + StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE + ) { + Err(AcmeError::RetryAt { + time: parse_retry_after(&response), + }) + } else { + Err(AcmeError::HttpStatus(response.status())) + } +} + +pub(crate) fn get_header(response: &Response, header: &'static str) -> AcmeResult { + match response.headers().get_all(header).iter().next_back() { + Some(value) => Ok(value + .to_str() + .map_err(|err| { + AcmeError::Invalid(format!("Failed to read header {}: {}", header, err)) + })? + .to_string()), + None => Err(AcmeError::Invalid(format!("Missing header: {}", header))), + } +} + +pub(crate) fn parse_retry_after(response: &Response) -> Option { + let value = response.headers().get("Retry-After")?.to_str().ok()?; + if let Ok(secs) = value.parse::() { + Some(Duration::from_secs(secs + 1)) + } else if let Ok(dt) = DateTime::parse_from_rfc2822(value) { + Utc::now() + .signed_duration_since(dt.with_timezone(&Utc)) + .to_std() + .map(|dur| dur + Duration::from_secs(1)) + .ok() + } else { + None + } +} diff --git a/crates/common/src/network/acme/jose.rs b/crates/common/src/network/acme/jose.rs index 0a25466b..9313de07 100644 --- a/crates/common/src/network/acme/jose.rs +++ b/crates/common/src/network/acme/jose.rs @@ -1,5 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + // Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0. +use crate::network::acme::{AcmeError, AcmeResult}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use ring::digest::{Digest, SHA256, digest}; @@ -14,7 +21,7 @@ pub(crate) fn sign( nonce: String, url: &str, payload: &str, -) -> trc::Result { +) -> AcmeResult { let jwk = match kid { None => Some(Jwk::new(key)), Some(_) => None, @@ -24,18 +31,14 @@ pub(crate) fn sign( let combined = format!("{}.{}", &protected, &payload); let signature = key .sign(&SystemRandom::new(), combined.as_bytes()) - .map_err(|err| { - trc::EventType::Acme(trc::AcmeEvent::Error) - .caused_by(trc::location!()) - .reason(err) - })?; + .map_err(|err| AcmeError::Crypto(format!("Failed to sign payload: {}", err)))?; 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)) + .map_err(Into::into) } pub(crate) fn eab_sign( @@ -43,7 +46,7 @@ pub(crate) fn eab_sign( kid: &str, hmac_key: &[u8], url: &str, -) -> trc::Result { +) -> AcmeResult { let protected = Protected::encode("HS256", None, kid.into(), None, url)?; let payload = Jwk::new(key).base64()?; let combined = format!("{}.{}", &protected, &payload); @@ -59,7 +62,7 @@ pub(crate) fn eab_sign( }) } -pub(crate) fn key_authorization(key: &EcdsaKeyPair, token: &str) -> trc::Result { +pub(crate) fn key_authorization(key: &EcdsaKeyPair, token: &str) -> AcmeResult { Ok(format!( "{}.{}", token, @@ -67,14 +70,14 @@ pub(crate) fn key_authorization(key: &EcdsaKeyPair, token: &str) -> trc::Result< )) } -pub(crate) fn key_authorization_sha256(key: &EcdsaKeyPair, token: &str) -> trc::Result { +pub(crate) fn key_authorization_sha256(key: &EcdsaKeyPair, token: &str) -> AcmeResult { key_authorization(key, token).map(|s| digest(&SHA256, s.as_bytes())) } pub(crate) fn key_authorization_sha256_base64( key: &EcdsaKeyPair, token: &str, -) -> trc::Result { +) -> AcmeResult { key_authorization_sha256(key, token).map(|s| URL_SAFE_NO_PAD.encode(s.as_ref())) } @@ -104,7 +107,7 @@ impl<'a> Protected<'a> { kid: Option<&'a str>, nonce: Option, url: &'a str, - ) -> trc::Result { + ) -> AcmeResult { serde_json::to_vec(&Protected { alg, jwk, @@ -112,7 +115,7 @@ impl<'a> Protected<'a> { nonce, url, }) - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err)) + .map_err(Into::into) .map(|v| URL_SAFE_NO_PAD.encode(v.as_slice())) } } @@ -141,13 +144,13 @@ impl Jwk { } } - pub(crate) fn base64(&self) -> trc::Result { + pub(crate) fn base64(&self) -> AcmeResult { serde_json::to_vec(self) - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err)) + .map_err(Into::into) .map(|v| URL_SAFE_NO_PAD.encode(v.as_slice())) } - pub(crate) fn thumb_sha256_base64(&self) -> trc::Result { + pub(crate) fn thumb_sha256_base64(&self) -> AcmeResult { Ok(URL_SAFE_NO_PAD.encode(digest( &SHA256, &serde_json::to_vec(&JwkThumb { @@ -156,7 +159,7 @@ impl Jwk { x: &self.x, y: &self.y, }) - .map_err(|err| trc::EventType::Acme(trc::AcmeEvent::Error).from_json_error(err))?, + .map_err(AcmeError::Json)?, ))) } } diff --git a/crates/common/src/network/acme/mod.rs b/crates/common/src/network/acme/mod.rs index 72f58c1c..deb25abb 100644 --- a/crates/common/src/network/acme/mod.rs +++ b/crates/common/src/network/acme/mod.rs @@ -4,130 +4,223 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -pub mod cache; +pub mod account; pub mod directory; +pub mod http; pub mod jose; pub mod order; +pub mod renew; pub mod resolver; -use self::directory::{Account, ChallengeType}; -use crate::Server; -use arc_swap::ArcSwap; -use dns_update::DnsUpdater; -use registry::schema::structs; +use chrono::{DateTime, Utc}; +use registry::schema::enums::AcmeChallengeType; use rustls::sign::CertifiedKey; -use std::{fmt::Debug, sync::Arc, time::Duration}; +use serde::Deserialize; +use std::{ + fmt::{Debug, Display}, + sync::Arc, + time::Duration, +}; +use store::registry::write::RegistryWriteResult; -pub struct AcmeProvider { - pub id: String, - pub directory_url: String, - pub domains: Vec, - pub contact: Vec, - pub challenge: ChallengeSettings, - pub eab: Option, - renew_before: chrono::Duration, - account_key: ArcSwap>, - default: bool, -} +pub type AcmeResult = Result; -#[derive(Clone)] -pub struct EabSettings { - pub kid: String, - pub hmac_key: Vec, -} - -#[derive(Clone)] -pub enum ChallengeSettings { - Http01, - TlsAlpn01, - Dns01 { - updater: DnsUpdater, - origin: Option, - polling_interval: Duration, - propagation_timeout: Duration, - ttl: u32, +pub enum AcmeError { + Http(reqwest::Error), + HttpStatus(reqwest::StatusCode), + Json(serde_json::Error), + Crypto(String), + Invalid(String), + AuthInvalid(AuthStatus), + OrderTimeout, + OrderInvalid, + AuthTimeout, + ChallengeNotSupported { + requested: ChallengeType, + supported: Vec, }, + Internal(trc::Error), + Registry(RegistryWriteResult), + RetryAt { + time: Option, + }, +} + +#[derive( + rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug, Clone, serde::Serialize, Deserialize, +)] +pub struct SerializedCert { + pub certificate: Vec, + pub private_key: Vec, +} + +pub struct PemCert { + pub certificate: String, + pub private_key: String, +} + +pub struct ParsedCert { + pub sans: Vec, + pub issuer: String, + pub valid_not_before: DateTime, + pub valid_not_after: DateTime, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Directory { + pub new_nonce: String, + pub new_account: String, + pub new_order: String, +} + +#[derive(Debug, Deserialize, Eq, PartialEq, Clone, Copy)] +pub enum ChallengeType { + #[serde(rename = "http-01")] + Http01, + #[serde(rename = "dns-01")] + Dns01, + #[serde(rename = "dns-persist-01")] + DnsPersist01, + #[serde(rename = "tls-alpn-01")] + TlsAlpn01, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Order { + #[serde(flatten)] + pub status: OrderStatus, + pub authorizations: Vec, + pub finalize: String, + pub error: Option, +} + +#[derive(Debug, Deserialize, Clone, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum OrderStatus { + Pending, + Ready, + Valid { certificate: String }, + Invalid, + Processing, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Auth { + pub status: AuthStatus, + pub identifier: Identifier, + pub challenges: Vec, + pub wildcard: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AuthStatus { + Pending, + Valid, + Invalid, + Revoked, + Expired, + Deactivated, +} + +#[derive(Clone, Debug, serde::Serialize, Deserialize)] +#[serde(tag = "type", content = "value", rename_all = "camelCase")] +pub enum Identifier { + Dns(String), +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Challenge { + #[serde(rename = "type")] + pub typ: ChallengeType, + pub url: String, + pub token: String, + pub error: Option, +} + +#[derive(Clone, Debug, serde::Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Problem { + #[serde(rename = "type")] + pub typ: Option, + pub detail: Option, } pub struct StaticResolver { pub key: Option>, } -impl AcmeProvider { - pub fn new(obj: structs::AcmeProvider) -> Self { - // TODO: Prefix contact with "mailto:" if not present - todo!() - } -} - -impl Server { - pub async fn init_acme(&self, provider: &AcmeProvider) -> trc::Result { - // Load account key from cache or generate a new one - if let Some(account_key) = self.load_account(provider).await? { - provider.account_key.store(Arc::new(account_key)); - } else { - let account_key = Account::generate_key_pair(); - self.store_account(provider, &account_key).await?; - provider.account_key.store(Arc::new(account_key)); - } - - // Load certificate from cache or request a new one - Ok(if let Some(pem) = self.load_cert(provider).await? { - self.process_cert(provider, pem, true).await? - } else { - Duration::from_millis(1000) - }) - } - - pub fn has_acme_tls_providers(&self) -> bool { - let todo = "fix"; - false - /*self.core - .acme - .providers - .values() - .any(|p| matches!(p.challenge, ChallengeSettings::TlsAlpn01))*/ - } - - pub fn has_acme_http_providers(&self) -> bool { - let todo = "fix"; - false - /*self.core - .acme - .providers - .values() - .any(|p| matches!(p.challenge, ChallengeSettings::Http01))*/ - } -} - -impl ChallengeSettings { - pub fn challenge_type(&self) -> ChallengeType { - match self { - ChallengeSettings::Http01 => ChallengeType::Http01, - ChallengeSettings::TlsAlpn01 => ChallengeType::TlsAlpn01, - ChallengeSettings::Dns01 { .. } => ChallengeType::Dns01, - } - } -} - impl Debug for StaticResolver { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("StaticResolver").finish() } } -impl Clone for AcmeProvider { - fn clone(&self) -> Self { - Self { - id: self.id.clone(), - directory_url: self.directory_url.clone(), - domains: self.domains.clone(), - contact: self.contact.clone(), - 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, +impl From for AcmeError { + fn from(err: reqwest::Error) -> Self { + AcmeError::Http(err) + } +} + +impl From for AcmeError { + fn from(err: serde_json::Error) -> Self { + AcmeError::Json(err) + } +} + +impl From for AcmeError { + fn from(err: trc::Error) -> Self { + AcmeError::Internal(err) + } +} + +impl From for ChallengeType { + fn from(value: AcmeChallengeType) -> Self { + match value { + AcmeChallengeType::Http01 => ChallengeType::Http01, + AcmeChallengeType::Dns01 => ChallengeType::Dns01, + AcmeChallengeType::TlsAlpn01 => ChallengeType::TlsAlpn01, + AcmeChallengeType::DnsPersist01 => ChallengeType::DnsPersist01, + } + } +} + +impl Display for AcmeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AcmeError::Http(err) => write!(f, "HTTP error: {}", err), + AcmeError::HttpStatus(status) => write!(f, "HTTP error: status code {}", status), + AcmeError::Json(err) => write!(f, "JSON error: {}", err), + AcmeError::Crypto(err) => write!(f, "Cryptographic error: {}", err), + AcmeError::Invalid(err) => write!(f, "Invalid request: {}", err), + AcmeError::AuthInvalid(status) => write!(f, "Authentication failed: {:?}", status), + AcmeError::OrderTimeout => write!(f, "Order processing timed out"), + AcmeError::OrderInvalid => write!(f, "Order is invalid"), + AcmeError::AuthTimeout => write!(f, "Authentication timed out"), + AcmeError::ChallengeNotSupported { + requested, + supported, + } => { + write!( + f, + "Challenge type {:?} not supported. Supported types: {:?}", + requested, supported + ) + } + AcmeError::Internal(err) => write!(f, "Internal error: {}", err), + AcmeError::Registry(err) => write!(f, "Registry error: {:?}", err), + AcmeError::RetryAt { time } => { + if let Some(time) = time { + write!(f, "Rate limited. Retry after {} seconds", time.as_secs()) + } else { + write!(f, "Rate limited. Retry after some time") + } + } } } } diff --git a/crates/common/src/network/acme/order.rs b/crates/common/src/network/acme/order.rs index 14c083d2..fc1979a2 100644 --- a/crates/common/src/network/acme/order.rs +++ b/crates/common/src/network/acme/order.rs @@ -1,248 +1,208 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + // Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0. -use chrono::{DateTime, TimeZone, Utc}; - -use compact_str::CompactString; -use dns_update::{DnsRecord, DnsRecordType}; +use crate::network::acme::directory::AcmeRequestBuilder; +use crate::network::acme::{ + AcmeError, AcmeResult, AuthStatus, ChallengeType, Identifier, OrderStatus, ParsedCert, PemCert, +}; +use crate::{KV_ACME, Server}; +use chrono::{TimeZone, Utc}; use futures::future::try_join_all; use rcgen::{CertificateParams, DistinguishedName, PKCS_ECDSA_P256_SHA256}; -use rustls::crypto::ring::sign::any_ecdsa_type; -use rustls::sign::CertifiedKey; -use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; -use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::collections::BTreeSet; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::time::Duration; use store::dispatch::lookup::KeyValue; -use trc::{AcmeEvent, DnsEvent, EventType}; +use trc::AcmeEvent; use x509_parser::parse_x509_certificate; +use x509_parser::prelude::{GeneralName, ParsedExtension}; -use crate::network::acme::ChallengeSettings; -use crate::network::acme::directory::Identifier; -use crate::{KV_ACME, Server}; +const HOSTNAMES: &[&str] = &["mta-sts", "ua-auto-config", "autoconfig", "autodiscover"]; -use super::AcmeProvider; -use super::directory::{Account, AuthStatus, Directory, OrderStatus}; - -impl Server { - pub(crate) async fn process_cert( +impl AcmeRequestBuilder { + pub async fn renew( &self, - provider: &AcmeProvider, - pem: Vec, - cached: bool, - ) -> trc::Result { - let (cert, validity) = parse_cert(&pem)?; - - self.set_cert(provider, Arc::new(cert)); - - let renew_at = (validity[1] - provider.renew_before - Utc::now()) - .max(chrono::Duration::zero()) - .to_std() - .unwrap_or_default(); - let renewal_date = validity[1] - provider.renew_before; - - trc::event!( - Acme(AcmeEvent::ProcessCert), - Id = provider.id.to_string(), - Hostname = provider.domains.as_slice(), - ValidFrom = trc::Value::Timestamp(validity[0].timestamp() as u64), - ValidTo = trc::Value::Timestamp(validity[1].timestamp() as u64), - Due = trc::Value::Timestamp(renewal_date.timestamp() as u64), - ); - - if !cached { - self.store_cert(provider, &pem).await?; - } - - Ok(renew_at) - } - - pub async fn renew(&self, provider: &AcmeProvider) -> trc::Result { - let mut backoff = 0; - loop { - match self.order(provider).await { - Ok(pem) => return self.process_cert(provider, pem, false).await, - Err(err) - if !err.matches(EventType::Acme(AcmeEvent::OrderInvalid)) && backoff < 9 => - { - trc::event!( - Acme(AcmeEvent::RenewBackoff), - Id = provider.id.to_string(), - Hostname = provider.domains.as_slice(), - Total = backoff, - NextRetry = 1 << backoff, - CausedBy = err, - ); - backoff += 1; - tokio::time::sleep(Duration::from_secs(1 << backoff)).await; - } - Err(err) => { - return Err(err - .details("Failed to renew certificate") - .ctx_unique(trc::Key::Id, provider.id.to_string()) - .ctx_unique(trc::Key::Hostname, provider.domains.as_slice())); - } + server: &Server, + domain: &str, + hostnames: &[String], + ) -> AcmeResult { + let domains = if hostnames.is_empty() { + if matches!( + self.challenge, + ChallengeType::Dns01 | ChallengeType::DnsPersist01 + ) { + vec![format!("*.{domain}")] + } else { + server + .core + .network + .server_name + .strip_suffix(domain) + .and_then(|host| host.strip_suffix(".")) + .map(|h| h.to_string()) + .into_iter() + .chain( + HOSTNAMES + .iter() + .map(|hostname| format!("{hostname}.{domain}")), + ) + .collect() } - } - } + } else { + hostnames + .iter() + .map(|hostname| format!("{hostname}.{domain}")) + .collect() + }; - async fn order(&self, provider: &AcmeProvider) -> trc::Result> { - let directory = Directory::discover(&provider.directory_url).await?; - let account = Account::create_with_keypair(directory, provider).await?; - - let mut params = CertificateParams::new(provider.domains.clone()); + let mut params = CertificateParams::new(domains.clone()); params.distinguished_name = DistinguishedName::new(); params.alg = &PKCS_ECDSA_P256_SHA256; - let cert = rcgen::Certificate::from_params(params).map_err(|err| { - EventType::Acme(AcmeEvent::Error) - .caused_by(trc::location!()) - .reason(err) - })?; + let cert = rcgen::Certificate::from_params(params) + .map_err(|err| AcmeError::Crypto(format!("Failed to generate certificate: {}", err)))?; + let response = self.new_order(domains.clone()).await?; + let order_url = response.location; + let mut order = response.body; + let mut retry_after = None; - let (order_url, mut order) = account.new_order(provider.domains.clone()).await?; loop { match order.status { OrderStatus::Pending => { let auth_futures = order .authorizations .iter() - .map(|url| self.authorize(provider, &account, url)); + .map(|url| self.authorize(server, url)); try_join_all(auth_futures).await?; trc::event!( Acme(AcmeEvent::AuthCompleted), - Id = provider.id.to_string(), - Hostname = provider.domains.as_slice(), + Url = self.directory.new_order.to_string(), + Hostname = domains.as_slice(), ); - order = account.order(&order_url).await?; + let response = self.order(&order_url).await?; + order = response.body; + retry_after = response.retry_after; } OrderStatus::Processing => { for i in 0u64..10 { trc::event!( Acme(AcmeEvent::OrderProcessing), - Id = provider.id.to_string(), - Hostname = provider.domains.as_slice(), + Url = self.directory.new_order.to_string(), + Hostname = domains.as_slice(), Total = i, ); - tokio::time::sleep(Duration::from_secs(1u64 << i)).await; - order = account.order(&order_url).await?; + tokio::time::sleep( + retry_after.unwrap_or_else(|| Duration::from_secs(1u64 << i)), + ) + .await; + let response = self + .order(&order_url) + .await? + .assert_reasonable_retry_after()?; + order = response.body; + retry_after = response.retry_after; if order.status != OrderStatus::Processing { break; } } if order.status == OrderStatus::Processing { - return Err(EventType::Acme(AcmeEvent::Error) - .caused_by(trc::location!()) - .details("Order processing timed out")); + return Err(AcmeError::OrderTimeout); } } OrderStatus::Ready => { trc::event!( Acme(AcmeEvent::OrderReady), - Id = provider.id.to_string(), - Hostname = provider.domains.as_slice(), + Url = self.directory.new_order.to_string(), + Hostname = domains.as_slice(), ); let csr = cert.serialize_request_der().map_err(|err| { - EventType::Acme(AcmeEvent::Error) - .caused_by(trc::location!()) - .reason(err) + AcmeError::Crypto(format!("Failed to serialize CSR: {}", err)) })?; - order = account.finalize(order.finalize, csr).await? + order = self.finalize(order.finalize, csr).await?.body; } OrderStatus::Valid { certificate } => { trc::event!( Acme(AcmeEvent::OrderValid), - Id = provider.id.to_string(), - Hostname = provider.domains.as_slice(), + Url = self.directory.new_order.to_string(), + Hostname = domains.as_slice(), ); - let pem = [ - &cert.serialize_private_key_pem(), - "\n", - &account.certificate(certificate).await?, - ] - .concat(); - return Ok(pem.into_bytes()); + let certificate = self.certificate(certificate).await?; + + return Ok(PemCert { + certificate, + private_key: cert.serialize_private_key_pem(), + }); } OrderStatus::Invalid => { - return Err(EventType::Acme(AcmeEvent::OrderInvalid).into_err()); + return Err(AcmeError::OrderInvalid); } } } } - async fn authorize( - &self, - provider: &AcmeProvider, - account: &Account, - url: &String, - ) -> trc::Result<()> { - let auth = account.auth(url).await?; + async fn authorize(&self, server: &Server, url: &String) -> AcmeResult<()> { + let response = self.auth(url).await?.assert_reasonable_retry_after()?; + let mut retry_after = response.retry_after; + let auth = response.body; + let (domain, challenge_url) = match auth.status { AuthStatus::Pending => { let Identifier::Dns(domain) = auth.identifier; - let challenge_type = provider.challenge.challenge_type(); trc::event!( Acme(AcmeEvent::AuthStart), Hostname = domain.to_string(), - Type = challenge_type.as_str(), - Id = provider.id.to_string(), + Type = self.challenge.as_str(), + Url = self.directory.new_order.to_string(), ); let challenge = auth .challenges .iter() - .find(|c| c.typ == challenge_type) - .ok_or( - EventType::Acme(AcmeEvent::OrderInvalid) - .into_err() - .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::Contents, - auth.challenges - .iter() - .map(|c| { - trc::Value::String(CompactString::const_new(c.typ.as_str())) - }) - .collect::>(), - ), - )?; + .find(|c| c.typ == self.challenge) + .ok_or(AcmeError::ChallengeNotSupported { + requested: self.challenge, + supported: auth.challenges.clone(), + })?; - match &provider.challenge { - ChallengeSettings::TlsAlpn01 => { - self.in_memory_store() + match &self.challenge { + ChallengeType::TlsAlpn01 => { + server + .in_memory_store() .key_set( KeyValue::with_prefix( KV_ACME, &domain, - account.tls_alpn_key(challenge, domain.clone())?, + self.tls_alpn_key(challenge, domain.clone())?, ) .expires(3600), ) .await?; } - ChallengeSettings::Http01 => { - self.in_memory_store() + ChallengeType::Http01 => { + server + .in_memory_store() .key_set( KeyValue::with_prefix( KV_ACME, &challenge.token, - account.http_proof(challenge)?, + self.http_proof(challenge)?, ) .expires(3600), ) .await?; } - ChallengeSettings::Dns01 { - updater, - origin, - polling_interval, - propagation_timeout, - ttl, - } => { - let dns_proof = account.dns_proof(challenge)?; + ChallengeType::Dns01 => { + let todo = "fix"; + todo!() + /*let dns_proof = self.dns_proof(challenge)?; let domain = domain.strip_prefix("*.").unwrap_or(&domain); let name = format!("_acme-challenge.{}", domain); let origin = origin @@ -259,7 +219,7 @@ impl Server { Hostname = name.to_string(), Reason = err.to_string(), Details = origin.to_string(), - Id = provider.id.to_string(), + Url = self.directory.new_order.to_string(), ); } @@ -276,7 +236,7 @@ impl Server { .await { return Err(EventType::Dns(DnsEvent::RecordCreationFailed) - .ctx(trc::Key::Id, provider.id.to_string()) + .ctx(trc::Key::Id, self.id.to_string()) .ctx(trc::Key::Hostname, name) .ctx(trc::Key::Details, origin) .reason(err)); @@ -286,7 +246,7 @@ impl Server { Dns(DnsEvent::RecordCreated), Hostname = name.to_string(), Details = origin.to_string(), - Id = provider.id.to_string(), + Url = self.directory.new_order.to_string(), ); // Wait for changes to propagate @@ -302,7 +262,7 @@ impl Server { } else { trc::event!( Dns(DnsEvent::RecordNotPropagated), - Id = provider.id.to_string(), + Url = self.directory.new_order.to_string(), Hostname = name.to_string(), Details = origin.to_string(), Result = result.to_string(), @@ -313,7 +273,7 @@ impl Server { Err(err) => { trc::event!( Dns(DnsEvent::RecordLookupFailed), - Id = provider.id.to_string(), + Url = self.directory.new_order.to_string(), Hostname = name.to_string(), Details = origin.to_string(), Reason = err.to_string(), @@ -327,112 +287,141 @@ impl Server { if did_propagate { trc::event!( Dns(DnsEvent::RecordPropagated), - Id = provider.id.to_string(), + Url = self.directory.new_order.to_string(), Hostname = name.to_string(), Details = origin.to_string(), ); } else { trc::event!( Dns(DnsEvent::RecordPropagationTimeout), - Id = provider.id.to_string(), + Url = self.directory.new_order.to_string(), Hostname = name.to_string(), Details = origin.to_string(), ); - } + }*/ } + ChallengeType::DnsPersist01 => return Ok(()), + ChallengeType::Unknown => unreachable!(), } - account.challenge(&challenge.url).await?; + self.challenge(&challenge.url).await?; (domain, challenge.url.clone()) } AuthStatus::Valid => return Ok(()), _ => { - return Err(EventType::Acme(AcmeEvent::AuthError) - .into_err() - .ctx(trc::Key::Id, provider.id.to_string()) - .ctx(trc::Key::Details, auth.status.as_str())); + return Err(AcmeError::AuthInvalid(auth.status)); } }; for i in 0u64..5 { - tokio::time::sleep(Duration::from_secs(1u64 << i)).await; - let auth = account.auth(url).await?; - match auth.status { + tokio::time::sleep(retry_after.unwrap_or_else(|| Duration::from_secs(1u64 << i))).await; + let response = self.auth(url).await?.assert_reasonable_retry_after()?; + retry_after = response.retry_after; + + match response.body.status { AuthStatus::Pending => { trc::event!( Acme(AcmeEvent::AuthPending), Hostname = domain.to_string(), - Id = provider.id.to_string(), + Url = self.directory.new_order.to_string(), Total = i, ); - account.challenge(&challenge_url).await? + self.challenge(&challenge_url).await? } AuthStatus::Valid => { trc::event!( Acme(AcmeEvent::AuthValid), Hostname = domain.to_string(), - Id = provider.id.to_string(), + Url = self.directory.new_order.to_string(), ); return Ok(()); } _ => { - return Err(EventType::Acme(AcmeEvent::AuthError) - .into_err() - .ctx(trc::Key::Id, provider.id.to_string()) - .ctx(trc::Key::Details, auth.status.as_str())); + return Err(AcmeError::AuthInvalid(response.body.status)); } } } - Err(EventType::Acme(AcmeEvent::AuthTooManyAttempts) - .into_err() - .ctx(trc::Key::Id, provider.id.to_string()) - .ctx(trc::Key::Hostname, domain)) + + Err(AcmeError::AuthTimeout) } } -fn parse_cert(pem: &[u8]) -> trc::Result<(CertifiedKey, [DateTime; 2])> { - let mut pems = pem::parse_many(pem).map_err(|err| { - EventType::Acme(AcmeEvent::Error) - .reason(err) - .caused_by(trc::location!()) - })?; - if pems.len() < 2 { - return Err(EventType::Acme(AcmeEvent::Error) - .caused_by(trc::location!()) - .ctx(trc::Key::Size, pems.len()) - .details("Too few PEMs")); - } - let pk = match any_ecdsa_type(&PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( - pems.remove(0).contents(), - ))) { - Ok(pk) => pk, - Err(err) => { - return Err(EventType::Acme(AcmeEvent::Error) - .reason(err) - .caused_by(trc::location!())); - } - }; - let cert_chain: Vec = pems - .into_iter() - .map(|p| CertificateDer::from(p.into_contents())) - .collect(); - let validity = match parse_x509_certificate(&cert_chain[0]) { - Ok((_, cert)) => { - let validity = cert.validity(); - [validity.not_before, validity.not_after].map(|t| { - Utc.timestamp_opt(t.timestamp(), 0) - .earliest() - .unwrap_or_default() +impl ParsedCert { + pub fn parse(certificate: impl AsRef<[u8]>) -> AcmeResult { + pem::parse_many(certificate) + .map_err(|err| AcmeError::Crypto(format!("Failed to parse PEM: {}", err))) + .and_then(|pems| { + pems.into_iter() + .next() + .ok_or_else(|| AcmeError::Crypto("No certificates found in PEM".to_string())) }) - } - Err(err) => { - return Err(EventType::Acme(AcmeEvent::Error) - .reason(err) - .caused_by(trc::location!())); - } - }; - let cert = CertifiedKey::new(cert_chain, pk); - Ok((cert, validity)) + .and_then(|der| { + parse_x509_certificate(der.contents()) + .map_err(|err| { + AcmeError::Crypto(format!("Failed to parse X.509 certificate: {}", err)) + }) + .and_then(|(_, cert)| { + // Add CNs and SANs to the list of names + let mut names: BTreeSet = BTreeSet::new(); + for name in cert.subject().iter_common_name() { + if let Ok(name) = name.as_str() { + names.insert(name.into()); + } + } + for ext in cert.extensions() { + if let ParsedExtension::SubjectAlternativeName(san) = + ext.parsed_extension() + { + for name in &san.general_names { + let name = match name { + GeneralName::DNSName(name) => (*name).into(), + GeneralName::IPAddress(ip) => match ip.len() { + 4 => Ipv4Addr::from(<[u8; 4]>::try_from(*ip).unwrap()) + .to_string(), + 16 => { + Ipv6Addr::from(<[u8; 16]>::try_from(*ip).unwrap()) + .to_string() + } + _ => continue, + }, + _ => { + continue; + } + }; + names.insert(name); + } + } + } + + Ok(ParsedCert { + sans: names.into_iter().collect(), + issuer: cert.tbs_certificate.issuer().to_string(), + valid_not_before: Utc + .timestamp_opt( + cert.tbs_certificate.validity().not_before.timestamp(), + 0, + ) + .single() + .ok_or_else(|| { + AcmeError::Crypto( + "Certificate not_before time is out of range".to_string(), + ) + })?, + valid_not_after: Utc + .timestamp_opt( + cert.tbs_certificate.validity().not_after.timestamp(), + 0, + ) + .single() + .ok_or_else(|| { + AcmeError::Crypto( + "Certificate not_after time is out of range".to_string(), + ) + })?, + }) + }) + }) + } } diff --git a/crates/common/src/network/acme/renew.rs b/crates/common/src/network/acme/renew.rs new file mode 100644 index 00000000..8eca2c89 --- /dev/null +++ b/crates/common/src/network/acme/renew.rs @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + Server, + ipc::{BroadcastEvent, RegistryChange}, + network::acme::{AcmeError, AcmeResult, ParsedCert, directory::AcmeRequestBuilder}, +}; +use registry::{ + schema::{ + enums::{AcmeRenewBefore, DnsRecordType}, + prelude::ObjectType, + structs::{ + AcmeProvider, Certificate, CertificateManagement, DnsManagement, Domain, PublicText, + PublicTextValue, SecretText, SecretTextValue, Task, TaskDnsManagement, + TaskDomainManagement, TaskStatus, + }, + }, + types::{datetime::UTCDateTime, id::ObjectId, map::Map}, +}; +use store::{ + registry::write::{RegistryWrite, RegistryWriteResult}, + write::now, +}; +use types::id::Id; + +impl Server { + pub async fn acme_renew(&self, domain_id: Id) -> AcmeResult> { + let Some(domain) = self.registry().object::(domain_id).await? else { + return Err(AcmeError::Invalid(format!( + "Domain with ID {} not found", + domain_id + ))); + }; + let cert = match domain.certificate_management { + CertificateManagement::Manual => { + return Err(AcmeError::Invalid( + "ACME not configured for domain".to_string(), + )); + } + CertificateManagement::Automatic(props) => props, + }; + let Some(acme_provider) = self + .registry() + .object::(cert.acme_provider_id) + .await? + else { + return Err(AcmeError::Invalid(format!( + "ACME provider with ID {} not found", + cert.acme_provider_id + ))); + }; + let renew_before = acme_provider.renew_before; + let pem_cert = AcmeRequestBuilder::new(acme_provider) + .await? + .renew( + self, + &domain.name, + &cert.subject_alternative_names.into_inner(), + ) + .await?; + let parsed_cert = ParsedCert::parse(&pem_cert.certificate)?; + + let certificate = Certificate { + private_key: SecretText::Text(SecretTextValue { + secret: pem_cert.private_key, + }), + certificate: PublicText::Text(PublicTextValue { + value: pem_cert.certificate, + }), + issuer: parsed_cert.issuer, + not_valid_after: UTCDateTime::from_timestamp(parsed_cert.valid_not_after.timestamp()), + not_valid_before: UTCDateTime::from_timestamp(parsed_cert.valid_not_before.timestamp()), + subject_alternative_names: Map::new(parsed_cert.sans), + }; + let expires_in = (parsed_cert.valid_not_after.timestamp() as u64).saturating_sub(now()); + if expires_in < 86400 { + return Err(AcmeError::Invalid(format!( + "Certificate expires in {} seconds, expected at least 86400 seconds", + expires_in + ))); + } + + match self + .registry() + .write(RegistryWrite::insert(&certificate.into())) + .await? + { + RegistryWriteResult::Success(id) => { + // Reload registry + let change = RegistryChange::Insert(ObjectId::new(ObjectType::Certificate, id)); + Box::pin(self.reload_registry(change)).await?; + self.cluster_broadcast(BroadcastEvent::RegistryChange(change)) + .await; + + // Schedule next renewal + let mut tasks = Vec::new(); + let renew_in = match renew_before { + AcmeRenewBefore::R12 => { + // 1/2 of the remaining time until expiration + expires_in / 2 + } + AcmeRenewBefore::R23 => { + // 2/3 of the remaining time until expiration + expires_in * 2 / 3 + } + AcmeRenewBefore::R34 => { + // 3/4 of the remaining time until expiration + expires_in * 3 / 4 + } + AcmeRenewBefore::R45 => { + // 4/5 of the remaining time until expiration + expires_in * 4 / 5 + } + }; + tasks.push(Task::AcmeRenewal(TaskDomainManagement { + domain_id, + status: TaskStatus::at(renew_in as i64), + })); + + // Update TLSA records + if let DnsManagement::Automatic(props) = &domain.dns_management + && props.dns_publish_records.contains(&DnsRecordType::Tlsa) + { + tasks.push(Task::DnsManagement(TaskDnsManagement { + domain_id, + on_success_renew_certificate: false, + status: TaskStatus::now(), + update_records: Map::new(vec![DnsRecordType::Tlsa]), + })); + } + + Ok(tasks) + } + err => Err(AcmeError::Registry(err)), + } + } +} diff --git a/crates/common/src/network/acme/resolver.rs b/crates/common/src/network/acme/resolver.rs index bdda0276..0ed3032a 100644 --- a/crates/common/src/network/acme/resolver.rs +++ b/crates/common/src/network/acme/resolver.rs @@ -4,11 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use super::{ - AcmeProvider, StaticResolver, - directory::{ACME_TLS_ALPN_NAME, SerializedCert}, +use crate::{ + KV_ACME, Server, + network::acme::{SerializedCert, StaticResolver, directory::ACME_TLS_ALPN_NAME}, }; -use crate::{KV_ACME, Server}; use rustls::{ ServerConfig, crypto::ring::sign::any_ecdsa_type, @@ -24,24 +23,6 @@ use store::{ use trc::AcmeEvent; impl Server { - pub(crate) fn set_cert(&self, provider: &AcmeProvider, cert: Arc) { - // Add certificates - let mut certificates = self.inner.data.tls_certificates.load().as_ref().clone(); - for domain in provider.domains.iter() { - certificates.insert( - domain.strip_prefix("*.").unwrap_or(domain.as_str()).into(), - cert.clone(), - ); - } - - // Add default certificate - if provider.default { - certificates.insert("*".into(), cert); - } - - self.inner.data.tls_certificates.store(certificates.into()); - } - pub(crate) async fn build_acme_certificate(&self, domain: &str) -> Option> { match self .in_memory_store() @@ -93,6 +74,26 @@ impl Server { } } } + + pub fn has_acme_tls_providers(&self) -> bool { + let todo = "fix"; + false + /*self.core + .acme + .providers + .values() + .any(|p| matches!(p.challenge, ChallengeSettings::TlsAlpn01))*/ + } + + pub fn has_acme_http_providers(&self) -> bool { + let todo = "fix"; + false + /*self.core + .acme + .providers + .values() + .any(|p| matches!(p.challenge, ChallengeSettings::Http01))*/ + } } impl ResolvesServerCert for StaticResolver { diff --git a/crates/common/src/network/dkim.rs b/crates/common/src/network/dkim.rs index 9c470958..08d1a57b 100644 --- a/crates/common/src/network/dkim.rs +++ b/crates/common/src/network/dkim.rs @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use chrono::Utc; use mail_auth::dkim::generate::DkimKeyPair; use mail_builder::encoders::base64::base64_encode; use registry::schema::enums::DkimSignatureType; @@ -47,3 +48,164 @@ pub async fn generate_dkim_private_key( }) .map_err(|err| err.to_string())) } + +/// Generate a DKIM selector from a template string. +/// +/// Supported variables: +/// - `{algorithm}` — signing algorithm in lowercase (`rsa`, `ed25519`) +/// - `{hash}` — hash algorithm (`sha256`) +/// - `{version}` — DKIM version number (`1`) +/// - `{date-}` — current UTC date formatted with chrono strftime (e.g. `{date-%Y%m%d}`) +/// - `{epoch}` — current UTC unix timestamp +/// +/// The output is sanitized to only contain valid DKIM selector characters (`a-zA-Z0-9._-`). +/// Returns an error if the template contains unrecognized variables or produces an empty selector. +pub fn generate_dkim_selector( + template: &str, + sig_type: DkimSignatureType, +) -> Result { + let now = Utc::now(); + let mut result = Vec::with_capacity(template.len()); + let mut chars = template.as_bytes(); + + while !chars.is_empty() { + // Find next '{' or consume literal text + let Some(open) = memchr(b'{', chars) else { + // No more variables — append remaining literal + // SAFETY: template is valid UTF-8, and we only slice on ASCII boundaries + result.extend( + chars + .iter() + .filter(|&&c| c.is_ascii_alphanumeric() || c == b'.' || c == b'-' || c == b'_'), + ); + break; + }; + + // Append literal before '{' + if open > 0 { + result.extend( + chars[..open] + .iter() + .filter(|&&c| c.is_ascii_alphanumeric() || c == b'.' || c == b'-' || c == b'_'), + ); + } + + // Find matching '}' + let rest = chars.get(open + 1..).unwrap_or_default(); + let Some(close) = memchr(b'}', rest) else { + return Err("unclosed '{' in template".into()); + }; + + let var = + std::str::from_utf8(&rest[..close]).map_err(|_| "invalid UTF-8 in variable name")?; + + match var { + "algorithm" => result.extend_from_slice(sig_type.algorithm().as_bytes()), + "hash" => result.extend_from_slice(sig_type.hash().as_bytes()), + "version" => result.extend_from_slice(sig_type.version().as_bytes()), + "epoch" => { + result.extend_from_slice(now.timestamp().to_string().as_bytes()); + } + v => { + if let Some(fmt) = v.strip_prefix("date-") { + if fmt.is_empty() { + return Err("empty strftime format in {date-}".into()); + } + let formatted = now.format(fmt).to_string(); + if formatted.is_empty() { + return Err(format!("date format '{fmt}' produced empty output")); + } + result.extend(formatted.as_bytes().iter().filter(|&&c| { + c.is_ascii_alphanumeric() || c == b'.' || c == b'-' || c == b'_' + })); + } else { + return Err(format!("unrecognized variable '{{{var}}}'")); + } + } + } + + chars = rest.get(close + 1..).unwrap_or_default(); + } + + if !result.is_empty() { + Ok(String::from_utf8(result).unwrap_or_default()) + } else { + Err("Selector cannot be empty".into()) + } +} + +#[inline] +fn memchr(needle: u8, haystack: &[u8]) -> Option { + haystack.iter().position(|&b| b == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_algorithm_date() { + let sel = generate_dkim_selector( + "{algorithm}-{date-%Y%m%d}", + DkimSignatureType::Dkim1RsaSha256, + ) + .unwrap(); + let today = Utc::now().format("%Y%m%d").to_string(); + assert_eq!(sel, format!("rsa-{today}")); + } + + #[test] + fn all_variables() { + let sel = generate_dkim_selector( + "v{version}-{algorithm}-{hash}-{epoch}", + DkimSignatureType::Dkim1Ed25519Sha256, + ) + .unwrap(); + assert!(sel.starts_with("v1-ed25519-sha256-")); + } + + #[test] + fn literal_only() { + let sel = + generate_dkim_selector("my-static-selector", DkimSignatureType::default()).unwrap(); + assert_eq!(sel, "my-static-selector"); + } + + #[test] + fn invalid_chars_stripped() { + let sel = generate_dkim_selector("{algorithm} {hash}", DkimSignatureType::Dkim1RsaSha256) + .unwrap(); + assert_eq!(sel, "rsasha256"); + } + + #[test] + fn unrecognized_variable_errors() { + let err = generate_dkim_selector("{bogus}", DkimSignatureType::default()).unwrap_err(); + assert!(err.contains("unrecognized variable")); + } + + #[test] + fn unclosed_brace_errors() { + let err = generate_dkim_selector("{algorithm", DkimSignatureType::default()).unwrap_err(); + assert!(err.contains("unclosed")); + } + + #[test] + fn empty_after_sanitization_errors() { + let err = generate_dkim_selector(" ", DkimSignatureType::default()).unwrap_err(); + assert!(err.contains("empty")); + } + + #[test] + fn empty_date_format_errors() { + let err = generate_dkim_selector("{date-}", DkimSignatureType::default()).unwrap_err(); + assert!(err.contains("empty strftime")); + } + + #[test] + fn date_month_only() { + let sel = generate_dkim_selector("{date-%Y%m}", DkimSignatureType::Dkim1RsaSha256).unwrap(); + let expected = Utc::now().format("%Y%m").to_string(); + assert_eq!(sel, expected); + } +} diff --git a/crates/jmap/src/registry/mapping/dkim.rs b/crates/jmap/src/registry/mapping/dkim.rs index 80ed71db..7c9f064e 100644 --- a/crates/jmap/src/registry/mapping/dkim.rs +++ b/crates/jmap/src/registry/mapping/dkim.rs @@ -7,22 +7,12 @@ use crate::registry::mapping::{ ObjectResponse, RegistrySetResponse, ValidationResult, principal::validate_tenant_quota, }; -use common::{ - config::smtp::auth::{DkimSigner, rsa_key_parse, simple_pem_parse}, - network::dkim::generate_dkim_private_key, -}; +use common::config::smtp::auth::{DkimSigner, rsa_key_parse, simple_pem_parse}; use jmap_proto::error::set::SetError; use mail_auth::common::crypto::Ed25519Key; use mail_builder::encoders::base64::base64_encode; use pkcs8::Document; -use registry::{ - jmap::IntoValue, - schema::{ - enums::TenantStorageQuota, - prelude::{MASKED_PASSWORD, Property}, - structs::{DkimPrivateKey, DkimSignature, SecretTextValue}, - }, -}; +use registry::schema::{enums::TenantStorageQuota, structs::DkimSignature}; use rsa::pkcs1::DecodeRsaPublicKey; pub(crate) async fn validate_dkim_signature( @@ -30,7 +20,7 @@ pub(crate) async fn validate_dkim_signature( key: &mut DkimSignature, old_key: Option<&DkimSignature>, ) -> ValidationResult { - let mut response = if old_key.is_none() { + let response = if old_key.is_none() { match validate_tenant_quota(set, TenantStorageQuota::MaxDkimKeys).await? { Ok(response) => response, Err(err) => { @@ -42,7 +32,7 @@ pub(crate) async fn validate_dkim_signature( }; // Generate private key if requested - let key_type = key.object_type(); + /*let key_type = key.object_type(); let pk = key.private_key_mut(); if let Some(old_key) = old_key && matches!(pk, DkimPrivateKey::Value(value) if value.secret == MASKED_PASSWORD) @@ -64,22 +54,26 @@ pub(crate) async fn validate_dkim_signature( return Ok(Err(SetError::forbidden().with_description(err.to_string()))); } } + }*/ + + if old_key.is_none_or(|old_key| old_key.private_key() != key.private_key()) + && let Err(err) = DkimSigner::new("example.com".to_string(), key.clone()).await + { + return Ok(Err(SetError::invalid_properties().with_description( + format!("Failed to validate DKIM signature: {err}"), + ))); } - // Verify signature - match DkimSigner::new("example.com".to_string(), key.clone()).await { - Ok(_) => Ok(Ok(response)), - Err(err) => Ok(Err(SetError::invalid_properties() - .with_description(format!("Failed to build DKIM signature: {err}")))), - } + Ok(Ok(response)) } pub async fn generate_dkim_public_key(key: &DkimSignature) -> trc::Result { match key { DkimSignature::Dkim1RsaSha256(key) => key .private_key - .pem() + .secret() .await + .map_err(|err| trc::DkimEvent::BuildError.reason(err)) .and_then(|pem| rsa_key_parse(pem.as_bytes())) .and_then(|pk| { Document::from_pkcs1_der(&pk.public_key()).map_err(|err| { @@ -94,8 +88,9 @@ pub async fn generate_dkim_public_key(key: &DkimSignature) -> trc::Result key .private_key - .pem() + .secret() .await + .map_err(|err| trc::DkimEvent::BuildError.reason(err)) .and_then(|pem| { simple_pem_parse(&pem).ok_or_else(|| { trc::EventType::Dkim(trc::DkimEvent::BuildError) diff --git a/crates/jmap/src/registry/mapping/domain.rs b/crates/jmap/src/registry/mapping/domain.rs new file mode 100644 index 00000000..f9811036 --- /dev/null +++ b/crates/jmap/src/registry/mapping/domain.rs @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::registry::mapping::{ + ObjectResponse, RegistrySetResponse, ValidationResult, principal::validate_tenant_quota, +}; +use common::network::dkim::generate_dkim_selector; +use jmap_proto::error::set::SetError; +use registry::{ + schema::{ + enums::{AcmeChallengeType, DkimSignatureType, TaskDkimRotationStage, TenantStorageQuota}, + prelude::Property, + structs::{ + AcmeProvider, CertificateManagement, DkimManagement, DkimManagementProperties, + DnsManagement, Domain, Task, TaskDkimRotation, TaskDnsManagement, TaskDomainManagement, + TaskStatus, + }, + }, + types::map::Map, +}; +use types::id::Id; + +pub(crate) async fn validate_domain( + set: &RegistrySetResponse<'_>, + domain: &mut Domain, + old_domain: Option<&Domain>, + tasks: &mut Vec, +) -> ValidationResult { + let response = if old_domain.is_none() { + match validate_tenant_quota(set, TenantStorageQuota::MaxDomains).await? { + Ok(response) => response, + Err(err) => { + return Ok(Err(err)); + } + } + } else { + ObjectResponse::default() + }; + + // Validate DKIM selector template + if let DkimManagement::Automatic(DkimManagementProperties { + selector_template, .. + }) = &domain.dkim_management + && old_domain.is_none_or(|old| { + matches!( + &old.dkim_management, + DkimManagement::Automatic(DkimManagementProperties { + selector_template: old_selector_template, + .. + }) if old_selector_template != selector_template + ) + }) + && let Err(err) = + generate_dkim_selector(selector_template, DkimSignatureType::Dkim1RsaSha256) + { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::SelectorTemplate) + .with_description(err))); + } + + // Schedule DNS update task + let will_trigger_acme = if let DnsManagement::Automatic(details) = &domain.dns_management + && old_domain.is_none_or(|old| !matches!(old.dns_management, DnsManagement::Automatic(_))) + { + let on_success_renew_certificate = old_domain.is_none() + && matches!( + domain.certificate_management, + CertificateManagement::Automatic(_) + ); + tasks.push(Task::DnsManagement(TaskDnsManagement { + domain_id: Id::default(), + update_records: Map::new(details.dns_publish_records.as_slice().to_vec()), + on_success_renew_certificate, + status: TaskStatus::now(), + })); + on_success_renew_certificate + } else { + false + }; + + // Schedule DKIM key rotation task + if matches!(domain.dkim_management, DkimManagement::Automatic(_)) + && old_domain.is_none_or(|old| !matches!(old.dkim_management, DkimManagement::Automatic(_))) + { + tasks.push(Task::DkimKeyRotation(TaskDkimRotation { + domain_id: Id::default(), + stage: if matches!(domain.dkim_management, DkimManagement::Automatic(_)) { + TaskDkimRotationStage::GenerateAndPublish + } else { + TaskDkimRotationStage::Generate + }, + status: TaskStatus::now(), + })); + } + + // Schedule ACME renewal task if needed + if !will_trigger_acme + && let CertificateManagement::Automatic(details) = &domain.certificate_management + && old_domain.is_none_or(|old| { + !matches!( + old.certificate_management, + CertificateManagement::Automatic(_) + ) + }) + { + let Some(provider) = set + .server + .registry() + .object::(details.acme_provider_id) + .await? + else { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::AcmeProviderId) + .with_description("ACME provider not found"))); + }; + + if matches!(provider.class, AcmeChallengeType::Dns01) + && !matches!(domain.dns_management, DnsManagement::Automatic(_)) + { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::AcmeProviderId) + .with_description( + "ACME provider requires automatic DNS management", + ))); + } + + tasks.push(Task::AcmeRenewal(TaskDomainManagement { + domain_id: Id::default(), + status: TaskStatus::now(), + })); + } + + Ok(Ok(response)) +} diff --git a/crates/jmap/src/registry/mapping/mod.rs b/crates/jmap/src/registry/mapping/mod.rs index f98e85b3..25a81526 100644 --- a/crates/jmap/src/registry/mapping/mod.rs +++ b/crates/jmap/src/registry/mapping/mod.rs @@ -24,6 +24,7 @@ use utils::map::vec_map::VecMap; pub mod account; pub mod action; pub mod dkim; +pub mod domain; pub mod log; pub mod principal; pub mod public_key; @@ -31,6 +32,7 @@ pub mod queued_message; pub mod report; pub mod spam_sample; pub mod task; +pub mod tls; // SPDX-SnippetBegin // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC diff --git a/crates/jmap/src/registry/mapping/principal.rs b/crates/jmap/src/registry/mapping/principal.rs index a38cc709..4f785b29 100644 --- a/crates/jmap/src/registry/mapping/principal.rs +++ b/crates/jmap/src/registry/mapping/principal.rs @@ -347,6 +347,10 @@ pub(crate) async fn validate_tenant_quota( } TenantStorageQuota::MaxDkimKeys => (ObjectType::DkimSignature, None, "DKIM keys"), TenantStorageQuota::MaxDnsServers => (ObjectType::DnsServer, None, "DNS servers"), + TenantStorageQuota::MaxDirectories => (ObjectType::Directory, None, "directories"), + TenantStorageQuota::MaxAcmeProviders => { + (ObjectType::AcmeProvider, None, "ACME providers") + } TenantStorageQuota::MaxDiskQuota => unreachable!(), }; let query = RegistryQuery::new(object_type).with_tenant(tenant_id.into()); diff --git a/crates/jmap/src/registry/mapping/task.rs b/crates/jmap/src/registry/mapping/task.rs index 9ddbee6b..60d38945 100644 --- a/crates/jmap/src/registry/mapping/task.rs +++ b/crates/jmap/src/registry/mapping/task.rs @@ -89,7 +89,10 @@ pub(crate) async fn task_set( | TaskType::IndexTrace | TaskType::AccountMaintenance | TaskType::StoreMaintenance - | TaskType::SpamFilterMaintenance => { + | TaskType::SpamFilterMaintenance + | TaskType::AcmeRenewal + | TaskType::DkimKeyRotation + | TaskType::DnsManagement => { let mut index = IndexBuilder::default(); task.index(&mut index); diff --git a/crates/jmap/src/registry/mapping/tls.rs b/crates/jmap/src/registry/mapping/tls.rs new file mode 100644 index 00000000..371247b3 --- /dev/null +++ b/crates/jmap/src/registry/mapping/tls.rs @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::registry::mapping::{ + ObjectResponse, RegistrySetResponse, ValidationResult, principal::validate_tenant_quota, +}; +use common::network::acme::{ + ParsedCert, + account::{EabSettings, acme_create_account}, +}; +use jmap_proto::error::set::SetError; +use registry::{ + jmap::JmapValue, + schema::{ + enums::TenantStorageQuota, + prelude::Property, + structs::{AcmeProvider, Certificate}, + }, + types::{datetime::UTCDateTime, map::Map}, +}; +use utils::map::vec_map::VecMap; + +pub(crate) async fn validate_acme_provider( + set: &RegistrySetResponse<'_>, + provider: &mut AcmeProvider, + unpatched_properties: VecMap>, +) -> ValidationResult { + let response = match validate_tenant_quota(set, TenantStorageQuota::MaxAcmeProviders).await? { + Ok(response) => response, + Err(err) => { + return Ok(Err(err)); + } + }; + + // Obtain EAB credentials + let mut eab_key_id = None; + let mut eab_hmac_key = None; + for (key, value) in unpatched_properties { + match (key, value) { + (Property::EabKeyId, JmapValue::Str(value)) => { + eab_key_id = Some(value); + } + (Property::EabHmacKey, JmapValue::Str(value)) => { + eab_hmac_key = Some(value); + } + (_, JmapValue::Null) => {} + _ => { + return Ok(Err(SetError::invalid_properties().with_property(key))); + } + } + } + + let eab = if let (Some(key_id), Some(hmac_key)) = (eab_key_id, eab_hmac_key) { + match EabSettings::new(key_id.into_owned(), hmac_key.as_ref()) { + Ok(eab) => Some(eab), + Err(err) => { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::EabKeyId) + .with_property(Property::EabHmacKey) + .with_description(format!("Invalid EAB credentials: {err}")))); + } + } + } else { + None + }; + + match acme_create_account(provider, eab).await { + Ok(_) => Ok(Ok(response)), + Err(err) => Ok(Err(SetError::invalid_properties() + .with_property(Property::Directory) + .with_description(format!("Failed to create ACME account: {err}")))), + } +} + +pub(crate) async fn validate_certificate( + cert: &mut Certificate, + old_cert: Option<&Certificate>, +) -> ValidationResult { + if old_cert.is_none_or(|old_cert| old_cert.certificate != cert.certificate) { + match cert.certificate.value().await { + Ok(pem) => match ParsedCert::parse(pem.as_ref()) { + Ok(parsed) => { + cert.not_valid_after = + UTCDateTime::from_timestamp(parsed.valid_not_after.timestamp()); + cert.not_valid_before = + UTCDateTime::from_timestamp(parsed.valid_not_before.timestamp()); + cert.issuer = parsed.issuer; + cert.subject_alternative_names = Map::new(parsed.sans); + + Ok(Ok(ObjectResponse::default())) + } + Err(err) => Ok(Err(SetError::invalid_properties() + .with_property(Property::Certificate) + .with_description(format!("Failed to read certificate: {err}")))), + }, + Err(err) => Ok(Err(SetError::invalid_properties() + .with_property(Property::Certificate) + .with_description(format!("Failed to read certificate: {err}")))), + } + } else { + Ok(Ok(ObjectResponse::default())) + } +} diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index c24ec6ae..2c4ede32 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -11,6 +11,7 @@ use crate::registry::{ account::account_set, action::action_set, dkim::validate_dkim_signature, + domain::validate_domain, map_bootstrap_error, principal::{ schedule_account_destruction, validate_account, validate_role, validate_tenant_quota, @@ -20,6 +21,7 @@ use crate::registry::{ report::report_set, spam_sample::spam_sample_set, task::task_set, + tls::{validate_acme_provider, validate_certificate}, }, }; use common::{ @@ -42,14 +44,17 @@ use registry::{ OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, Object, ObjectInner, ObjectType, Property, }, - structs::{Account, DkimSignature, PublicKey, Role}, + structs::{Account, Certificate, DkimSignature, Domain, PublicKey, Role, Task}, }, types::id::ObjectId, }; use std::borrow::Cow; -use store::registry::{ - bootstrap::Bootstrap, - write::{RegistryWrite, RegistryWriteResult}, +use store::{ + registry::{ + bootstrap::Bootstrap, + write::{RegistryWrite, RegistryWriteResult}, + }, + write::BatchBuilder, }; use trc::AddContext; use types::id::Id; @@ -392,6 +397,7 @@ impl RegistrySet for Server { } // Validate objects + let mut tasks = Vec::new(); let result = match &mut new_object.inner { ObjectInner::Account(account) => { validate_account(&set, account, modification.as_account()).await? @@ -420,8 +426,9 @@ impl RegistrySet for Server { validate_dkim_signature(&set, key, modification.as_dkim_signature()) .await? } - ObjectInner::Domain(_) if is_create => { - validate_tenant_quota(&set, TenantStorageQuota::MaxDomains).await? + ObjectInner::Domain(domain) => { + validate_domain(&set, domain, modification.as_domain(), &mut tasks) + .await? } ObjectInner::MailingList(_) if is_create => { validate_tenant_quota(&set, TenantStorageQuota::MaxMailingLists).await? @@ -432,6 +439,15 @@ impl RegistrySet for Server { ObjectInner::DnsServer(_) if is_create => { validate_tenant_quota(&set, TenantStorageQuota::MaxDnsServers).await? } + ObjectInner::Directory(_) if is_create => { + validate_tenant_quota(&set, TenantStorageQuota::MaxDirectories).await? + } + ObjectInner::AcmeProvider(provider) if is_create => { + validate_acme_provider(&set, provider, unpatched_properties).await? + } + ObjectInner::Certificate(cert) => { + validate_certificate(cert, modification.as_certificate()).await? + } _ => Ok(ObjectResponse::default()), }; @@ -504,7 +520,7 @@ impl RegistrySet for Server { } }; - match (modification, result) { + let object_id = match (modification, result) { (Modification::Update { id, object }, RegistryWriteResult::Success(_)) => { cache_invalidator.process_update(id, &object, &new_object); set.response.updated.append( @@ -515,6 +531,7 @@ impl RegistrySet for Server { None }, ); + Some(id) } ( Modification::Create { client_id, .. }, @@ -524,15 +541,36 @@ impl RegistrySet for Server { set.response .created .insert(client_id, JmapValue::Object(response.object)); + Some(id) } (Modification::Update { id, .. }, err) => { set.response.not_updated.append(id, map_write_error(err)); + None } (Modification::Create { client_id, .. }, err) => { set.response .not_created .append(client_id, map_write_error(err)); + None } + }; + + // Dispatch tasks + if !tasks.is_empty() + && let Some(object_id) = object_id + { + let mut batch = BatchBuilder::new(); + for mut task in tasks.drain(..) { + match &mut task { + Task::AcmeRenewal(task) => task.domain_id = object_id, + Task::DkimKeyRotation(task) => task.domain_id = object_id, + Task::DnsManagement(task) => task.domain_id = object_id, + _ => unreachable!(), + } + batch.schedule_task(task); + } + set.server.store().write(batch.build_all()).await?; + set.server.notify_task_queue(); } } @@ -723,6 +761,26 @@ impl Modification { }, } } + + fn as_domain(&self) -> Option<&Domain> { + match self { + Modification::Create { .. } => None, + Modification::Update { object, .. } => match &object.inner { + ObjectInner::Domain(domain) => Some(domain), + _ => None, + }, + } + } + + fn as_certificate(&self) -> Option<&Certificate> { + match self { + Modification::Create { .. } => None, + Modification::Update { object, .. } => match &object.inner { + ObjectInner::Certificate(cert) => Some(cert), + _ => None, + }, + } + } } pub(crate) fn map_write_error(err: RegistryWriteResult) -> SetError { diff --git a/crates/registry/src/types/map.rs b/crates/registry/src/types/map.rs index 654f58b5..5f03e13d 100644 --- a/crates/registry/src/types/map.rs +++ b/crates/registry/src/types/map.rs @@ -38,36 +38,59 @@ impl Map { Self(Vec::with_capacity(capacity)) } + #[inline(always)] + pub fn contains(&self, item: &T) -> bool { + self.0.contains(item) + } + + #[inline(always)] pub fn len(&self) -> usize { self.0.len() } + #[inline(always)] pub fn is_empty(&self) -> bool { self.0.is_empty() } + #[inline(always)] pub fn into_inner(self) -> Vec { self.0 } + #[inline(always)] + pub fn inner_mut(&mut self) -> &mut Vec { + &mut self.0 + } + + #[inline(always)] pub fn iter(&self) -> impl Iterator { self.0.iter() } + #[inline(always)] + pub fn iter_mut(&mut self) -> impl Iterator { + self.0.iter_mut() + } + + #[inline(always)] pub fn as_slice(&self) -> &[T] { &self.0 } + #[inline(always)] pub fn push(&mut self, item: T) { if !self.0.contains(&item) { self.0.push(item); } } + #[inline(always)] pub fn push_unchecked(&mut self, item: T) { self.0.push(item); } + #[inline(always)] pub fn clear(&mut self) { self.0.clear(); } diff --git a/crates/registry/src/utils/dkim.rs b/crates/registry/src/utils/dkim.rs new file mode 100644 index 00000000..6f8eee4f --- /dev/null +++ b/crates/registry/src/utils/dkim.rs @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::schema::enums::DkimSignatureType; + +impl DkimSignatureType { + pub const fn algorithm(self) -> &'static str { + match self { + Self::Dkim1Ed25519Sha256 => "ed25519", + Self::Dkim1RsaSha256 => "rsa", + } + } + + pub const fn hash(self) -> &'static str { + match self { + Self::Dkim1Ed25519Sha256 | Self::Dkim1RsaSha256 => "sha256", + } + } + + pub const fn version(self) -> &'static str { + match self { + Self::Dkim1Ed25519Sha256 | Self::Dkim1RsaSha256 => "1", + } + } +} diff --git a/crates/registry/src/utils/mod.rs b/crates/registry/src/utils/mod.rs index a7f6e620..349ea18e 100644 --- a/crates/registry/src/utils/mod.rs +++ b/crates/registry/src/utils/mod.rs @@ -4,14 +4,13 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::borrow::Cow; - -use crate::schema::prelude::{DkimPrivateKey, DkimSignature, Roles}; +use crate::schema::prelude::{DkimSignature, Roles, SecretKey}; use types::id::Id; pub mod account; pub mod archived_item; pub mod cron; +pub mod dkim; pub mod http; pub mod report; pub mod secret; @@ -27,28 +26,17 @@ impl Roles { } impl DkimSignature { - pub fn private_key(&self) -> &DkimPrivateKey { + pub fn private_key(&self) -> &SecretKey { match self { DkimSignature::Dkim1Ed25519Sha256(signature) => &signature.private_key, DkimSignature::Dkim1RsaSha256(signature) => &signature.private_key, } } - pub fn private_key_mut(&mut self) -> &mut DkimPrivateKey { + pub fn private_key_mut(&mut self) -> &mut SecretKey { match self { DkimSignature::Dkim1Ed25519Sha256(signature) => &mut signature.private_key, DkimSignature::Dkim1RsaSha256(signature) => &mut signature.private_key, } } } - -impl DkimPrivateKey { - pub async fn pem(&self) -> trc::Result> { - match self { - DkimPrivateKey::Value(value) => Ok(Cow::Borrowed(value.secret.as_str())), - DkimPrivateKey::File(file) => file.secret().await.map(Cow::Owned), - DkimPrivateKey::Generate => Err("Key is in invalid generate state".to_string()), - } - .map_err(|err| trc::DkimEvent::BuildError.reason(err)) - } -} diff --git a/crates/registry/src/utils/task.rs b/crates/registry/src/utils/task.rs index f7d354df..db53da18 100644 --- a/crates/registry/src/utils/task.rs +++ b/crates/registry/src/utils/task.rs @@ -26,6 +26,9 @@ impl Task { Task::AccountMaintenance(task) => task.status = status, Task::StoreMaintenance(task) => task.status = status, Task::SpamFilterMaintenance(task) => task.status = status, + Task::AcmeRenewal(task) => task.status = status, + Task::DkimKeyRotation(task) => task.status = status, + Task::DnsManagement(task) => task.status = status, } } @@ -45,6 +48,9 @@ impl Task { Task::AccountMaintenance(task) => &task.status, Task::StoreMaintenance(task) => &task.status, Task::SpamFilterMaintenance(task) => &task.status, + Task::AcmeRenewal(task) => &task.status, + Task::DkimKeyRotation(task) => &task.status, + Task::DnsManagement(task) => &task.status, } } @@ -80,6 +86,9 @@ impl Task { Task::AccountMaintenance(_) => Permission::TaskAccountMaintenance, Task::StoreMaintenance(_) => Permission::TaskStoreMaintenance, Task::SpamFilterMaintenance(_) => Permission::TaskSpamFilterMaintenance, + Task::AcmeRenewal(_) => Permission::TaskAcmeRenewal, + Task::DkimKeyRotation(_) => Permission::TaskDkimKeyRotation, + Task::DnsManagement(_) => Permission::TaskDnsManagement, } } } diff --git a/crates/services/src/task_manager/manager.rs b/crates/services/src/task_manager/manager.rs index 1d25010d..87eefeaf 100644 --- a/crates/services/src/task_manager/manager.rs +++ b/crates/services/src/task_manager/manager.rs @@ -98,7 +98,10 @@ pub fn spawn_task_manager(inner: Arc) { | TaskType::MergeThreads | TaskType::DmarcReport | TaskType::TlsReport - | TaskType::RestoreArchivedItem => TASK_QUEUE_BUFFER, + | TaskType::RestoreArchivedItem + | TaskType::AcmeRenewal + | TaskType::DkimKeyRotation + | TaskType::DnsManagement => TASK_QUEUE_BUFFER, }; let (tx, mut rx) = mpsc::channel::(channel_capacity); @@ -237,6 +240,9 @@ pub fn spawn_task_manager(inner: Arc) { Task::IndexDocument(_) | Task::UnindexDocument(_) | Task::IndexTrace(_) => unreachable!(), + Task::AcmeRenewal(task_domain_management) => todo!(), + Task::DkimKeyRotation(task_dkim_rotation) => todo!(), + Task::DnsManagement(task_dns_management) => todo!(), }; refresh_queue = result.is_retry(); @@ -353,7 +359,10 @@ impl TaskQueueManager for Server { | TaskType::MergeThreads | TaskType::DmarcReport | TaskType::TlsReport - | TaskType::RestoreArchivedItem => true, + | TaskType::RestoreArchivedItem + | TaskType::AcmeRenewal + | TaskType::DkimKeyRotation + | TaskType::DnsManagement => true, }; if !enabled { diff --git a/crates/services/src/task_manager/mod.rs b/crates/services/src/task_manager/mod.rs index e8d2b0e0..dc5fb66b 100644 --- a/crates/services/src/task_manager/mod.rs +++ b/crates/services/src/task_manager/mod.rs @@ -97,6 +97,9 @@ impl TaskInfo for Task { Task::AccountMaintenance(_) => "AccountMaintenance", Task::StoreMaintenance(_) => "StoreMaintenance", Task::SpamFilterMaintenance(_) => "SpamFilterMaintenance", + Task::AcmeRenewal(_) => "AcmeRenewal", + Task::DkimKeyRotation(_) => "DkimKeyRotation", + Task::DnsManagement(_) => "DnsManagement", } } } diff --git a/crates/trc/src/event/enums.rs b/crates/trc/src/event/enums.rs index 08f9ffac..d0e9a3d8 100644 --- a/crates/trc/src/event/enums.rs +++ b/crates/trc/src/event/enums.rs @@ -30,7 +30,6 @@ pub enum EventType { Jmap(JmapEvent), Limit(LimitEvent), MailAuth(MailAuthEvent), - Manage(ManageEvent), ManageSieve(ManageSieveEvent), MessageIngest(MessageIngestEvent), Milter(MilterEvent), @@ -399,12 +398,6 @@ pub enum MailAuthEvent { PolicyNotAligned = 255, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[repr(u16)] -pub enum ManageEvent { - Reserved6 = 277, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(u16)] pub enum ManageSieveEvent { @@ -840,6 +833,7 @@ pub enum TlsEvent { CertificateNotFound = 542, NoCertificatesAvailable = 546, MultipleCertificatesAvailable = 545, + ExpiredCertificateRemoved = 277, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/crates/trc/src/event/enums_impl.rs b/crates/trc/src/event/enums_impl.rs index 481a7fb3..b279cf28 100644 --- a/crates/trc/src/event/enums_impl.rs +++ b/crates/trc/src/event/enums_impl.rs @@ -260,7 +260,6 @@ impl EventType { b"mail-auth.dns-record-not-found" => EventType::MailAuth(MailAuthEvent::DnsRecordNotFound), b"mail-auth.dns-invalid-record-type" => EventType::MailAuth(MailAuthEvent::DnsInvalidRecordType), b"mail-auth.policy-not-aligned" => EventType::MailAuth(MailAuthEvent::PolicyNotAligned), - b"manage.reserved6" => EventType::Manage(ManageEvent::Reserved6), b"manage-sieve.connection-start" => EventType::ManageSieve(ManageSieveEvent::ConnectionStart), b"manage-sieve.connection-end" => EventType::ManageSieve(ManageSieveEvent::ConnectionEnd), b"manage-sieve.create-script" => EventType::ManageSieve(ManageSieveEvent::CreateScript), @@ -588,6 +587,7 @@ impl EventType { b"tls.certificate-not-found" => EventType::Tls(TlsEvent::CertificateNotFound), b"tls.no-certificates-available" => EventType::Tls(TlsEvent::NoCertificatesAvailable), b"tls.multiple-certificates-available" => EventType::Tls(TlsEvent::MultipleCertificatesAvailable), + b"tls.expired-certificate-removed" => EventType::Tls(TlsEvent::ExpiredCertificateRemoved), b"tls-rpt.record-fetch" => EventType::TlsRpt(TlsRptEvent::RecordFetch), b"tls-rpt.record-fetch-error" => EventType::TlsRpt(TlsRptEvent::RecordFetchError), b"tls-rpt.record-not-found" => EventType::TlsRpt(TlsRptEvent::RecordNotFound), @@ -918,7 +918,6 @@ impl EventType { "mail-auth.dns-invalid-record-type" } EventType::MailAuth(MailAuthEvent::PolicyNotAligned) => "mail-auth.policy-not-aligned", - EventType::Manage(ManageEvent::Reserved6) => "manage.reserved6", EventType::ManageSieve(ManageSieveEvent::ConnectionStart) => { "manage-sieve.connection-start" } @@ -1318,6 +1317,9 @@ impl EventType { EventType::Tls(TlsEvent::MultipleCertificatesAvailable) => { "tls.multiple-certificates-available" } + EventType::Tls(TlsEvent::ExpiredCertificateRemoved) => { + "tls.expired-certificate-removed" + } EventType::TlsRpt(TlsRptEvent::RecordFetch) => "tls-rpt.record-fetch", EventType::TlsRpt(TlsRptEvent::RecordFetchError) => "tls-rpt.record-fetch-error", EventType::TlsRpt(TlsRptEvent::RecordNotFound) => "tls-rpt.record-not-found", @@ -1591,7 +1593,6 @@ impl EventType { EventType::MailAuth(MailAuthEvent::DnsRecordNotFound) => 250, EventType::MailAuth(MailAuthEvent::DnsInvalidRecordType) => 249, EventType::MailAuth(MailAuthEvent::PolicyNotAligned) => 255, - EventType::Manage(ManageEvent::Reserved6) => 277, EventType::ManageSieve(ManageSieveEvent::ConnectionStart) => 259, EventType::ManageSieve(ManageSieveEvent::ConnectionEnd) => 258, EventType::ManageSieve(ManageSieveEvent::CreateScript) => 260, @@ -1919,6 +1920,7 @@ impl EventType { EventType::Tls(TlsEvent::CertificateNotFound) => 542, EventType::Tls(TlsEvent::NoCertificatesAvailable) => 546, EventType::Tls(TlsEvent::MultipleCertificatesAvailable) => 545, + EventType::Tls(TlsEvent::ExpiredCertificateRemoved) => 277, EventType::TlsRpt(TlsRptEvent::RecordFetch) => 540, EventType::TlsRpt(TlsRptEvent::RecordFetchError) => 541, EventType::TlsRpt(TlsRptEvent::RecordNotFound) => 560, @@ -2210,7 +2212,6 @@ impl EventType { 250 => Some(EventType::MailAuth(MailAuthEvent::DnsRecordNotFound)), 249 => Some(EventType::MailAuth(MailAuthEvent::DnsInvalidRecordType)), 255 => Some(EventType::MailAuth(MailAuthEvent::PolicyNotAligned)), - 277 => Some(EventType::Manage(ManageEvent::Reserved6)), 259 => Some(EventType::ManageSieve(ManageSieveEvent::ConnectionStart)), 258 => Some(EventType::ManageSieve(ManageSieveEvent::ConnectionEnd)), 260 => Some(EventType::ManageSieve(ManageSieveEvent::CreateScript)), @@ -2562,6 +2563,7 @@ impl EventType { 542 => Some(EventType::Tls(TlsEvent::CertificateNotFound)), 546 => Some(EventType::Tls(TlsEvent::NoCertificatesAvailable)), 545 => Some(EventType::Tls(TlsEvent::MultipleCertificatesAvailable)), + 277 => Some(EventType::Tls(TlsEvent::ExpiredCertificateRemoved)), 540 => Some(EventType::TlsRpt(TlsRptEvent::RecordFetch)), 541 => Some(EventType::TlsRpt(TlsRptEvent::RecordFetchError)), 560 => Some(EventType::TlsRpt(TlsRptEvent::RecordNotFound)), @@ -2832,6 +2834,7 @@ impl EventType { EventType::Telemetry(TelemetryEvent::AlertMessage) => Level::Info, EventType::Telemetry(TelemetryEvent::MetricsCollected) => Level::Info, EventType::Tls(TlsEvent::Handshake) => Level::Info, + EventType::Tls(TlsEvent::ExpiredCertificateRemoved) => Level::Info, EventType::TlsRpt(TlsRptEvent::RecordFetch) => Level::Info, EventType::TlsRpt(TlsRptEvent::RecordFetchError) => Level::Info, EventType::TlsRpt(TlsRptEvent::RecordNotFound) => Level::Info, @@ -3229,7 +3232,6 @@ impl EventType { EventType::MailAuth(MailAuthEvent::DnsRecordNotFound) => "DNS record not found", EventType::MailAuth(MailAuthEvent::DnsInvalidRecordType) => "Invalid DNS record type", EventType::MailAuth(MailAuthEvent::PolicyNotAligned) => "Policy not aligned", - EventType::Manage(ManageEvent::Reserved6) => "Management error", EventType::ManageSieve(ManageSieveEvent::ConnectionStart) => { "ManageSieve connection started" } @@ -3641,6 +3643,9 @@ impl EventType { EventType::Tls(TlsEvent::MultipleCertificatesAvailable) => { "Multiple TLS certificates available" } + EventType::Tls(TlsEvent::ExpiredCertificateRemoved) => { + "Certificate expired and removed" + } EventType::TlsRpt(TlsRptEvent::RecordFetch) => "Fetched TLS-RPT record", EventType::TlsRpt(TlsRptEvent::RecordFetchError) => "Error fetching TLS-RPT record", EventType::TlsRpt(TlsRptEvent::RecordNotFound) => "TLS-RPT record not found", @@ -4096,7 +4101,6 @@ impl EventType { "The DNS record type is invalid" } EventType::MailAuth(MailAuthEvent::PolicyNotAligned) => "The policy is not aligned", - EventType::Manage(ManageEvent::Reserved6) => "A management error occurred", EventType::ManageSieve(ManageSieveEvent::ConnectionStart) => { "ManageSieve connection started" } @@ -4742,6 +4746,9 @@ impl EventType { EventType::Tls(TlsEvent::MultipleCertificatesAvailable) => { "Multiple TLS certificates are available" } + EventType::Tls(TlsEvent::ExpiredCertificateRemoved) => { + "A TLS certificate has expired and was removed from the store" + } EventType::TlsRpt(TlsRptEvent::RecordFetch) => "The TLS-RPT record has been fetched", EventType::TlsRpt(TlsRptEvent::RecordFetchError) => { "An error occurred while fetching the TLS-RPT record" @@ -4861,7 +4868,6 @@ impl EventType { EventType::Limit(LimitEvent::BlobQuota) => "Blob quota exceeded", EventType::Limit(LimitEvent::TenantQuota) => "Tenant quota exceeded", EventType::Limit(LimitEvent::TooManyRequests) => "Too many requests", - EventType::Manage(ManageEvent::Reserved6) => "Management API Error", EventType::ManageSieve(ManageSieveEvent::ConnectionStart) => "ManageSieve error", EventType::ManageSieve(ManageSieveEvent::ConnectionEnd) => "ManageSieve error", EventType::ManageSieve(ManageSieveEvent::CreateScript) => "ManageSieve error", @@ -5297,7 +5303,6 @@ impl EventType { EventType::MailAuth(MailAuthEvent::DnsRecordNotFound), EventType::MailAuth(MailAuthEvent::DnsInvalidRecordType), EventType::MailAuth(MailAuthEvent::PolicyNotAligned), - EventType::Manage(ManageEvent::Reserved6), EventType::ManageSieve(ManageSieveEvent::ConnectionStart), EventType::ManageSieve(ManageSieveEvent::ConnectionEnd), EventType::ManageSieve(ManageSieveEvent::CreateScript), @@ -5625,6 +5630,7 @@ impl EventType { EventType::Tls(TlsEvent::CertificateNotFound), EventType::Tls(TlsEvent::NoCertificatesAvailable), EventType::Tls(TlsEvent::MultipleCertificatesAvailable), + EventType::Tls(TlsEvent::ExpiredCertificateRemoved), EventType::TlsRpt(TlsRptEvent::RecordFetch), EventType::TlsRpt(TlsRptEvent::RecordFetchError), EventType::TlsRpt(TlsRptEvent::RecordNotFound), diff --git a/crates/trc/src/event/mod.rs b/crates/trc/src/event/mod.rs index 030e3014..4ebaebac 100644 --- a/crates/trc/src/event/mod.rs +++ b/crates/trc/src/event/mod.rs @@ -416,28 +416,6 @@ impl AuthEvent { } } -impl ManageEvent { - #[inline(always)] - pub fn ctx(self, key: Key, value: impl Into) -> Error { - self.into_err().ctx(key, value) - } - - #[inline(always)] - pub fn caused_by(self, error: impl Into) -> Error { - self.into_err().caused_by(error) - } - - #[inline(always)] - pub fn reason(self, error: impl Display) -> Error { - self.into_err().reason(error) - } - - #[inline(always)] - pub fn into_err(self) -> Error { - Error::new(EventType::Manage(self)) - } -} - impl JmapEvent { #[inline(always)] pub fn ctx(self, key: Key, value: impl Into) -> Error { diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 8bbc3409..37e6159a 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [features] #default = ["sqlite", "postgres", "mysql", "rocks", "s3", "redis", "nats", "azure", "foundationdb"] #default = ["sqlite", "postgres", "mysql", "rocks", "s3", "redis", "foundationdb"] -default = ["rocks", "postgres", "redis", "nats"] +default = ["rocks"] sqlite = ["store/sqlite", "directory/sqlite"] foundationdb = ["store/foundation", "common/foundation"] postgres = ["store/postgres", "directory/postgres"]