From ffba9b5a6102dd24ee66cd95604fcd0eaa8d26d7 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Fri, 5 Jan 2024 18:44:22 +0100 Subject: [PATCH] ACME TLS implementation using TLS-ALPN-01 - closes #160 --- CHANGELOG.md | 1 + Cargo.lock | 198 ++++++---- crates/cli/src/main.rs | 2 +- crates/cli/src/modules/cli.rs | 8 +- crates/cli/src/modules/database.rs | 12 +- crates/imap/src/core/session.rs | 2 +- crates/install/Cargo.toml | 1 + crates/install/src/main.rs | 97 ++--- crates/jmap/src/api/admin.rs | 13 +- crates/jmap/src/api/http.rs | 65 ++-- crates/jmap/src/lib.rs | 4 +- crates/jmap/src/services/housekeeper.rs | 39 +- crates/main/src/main.rs | 15 +- crates/managesieve/src/core/session.rs | 2 +- crates/smtp/src/core/management.rs | 12 +- crates/smtp/src/core/mod.rs | 4 +- crates/smtp/src/inbound/spawn.rs | 2 +- crates/store/src/backend/fs/mod.rs | 2 +- crates/utils/Cargo.toml | 10 + crates/utils/src/acme/cache.rs | 94 +++++ crates/utils/src/acme/directory.rs | 364 ++++++++++++++++++ crates/utils/src/acme/jose.rs | 140 +++++++ crates/utils/src/acme/mod.rs | 206 ++++++++++ crates/utils/src/acme/order.rs | 300 +++++++++++++++ crates/utils/src/acme/resolver.rs | 81 ++++ crates/utils/src/config/certificate.rs | 99 ----- crates/utils/src/config/listener.rs | 345 ++++++++++------- crates/utils/src/config/mod.rs | 16 +- crates/utils/src/config/tls.rs | 190 +++++++++ crates/utils/src/lib.rs | 1 + crates/utils/src/listener/listen.rs | 50 ++- crates/utils/src/listener/mod.rs | 51 ++- crates/utils/src/listener/tls.rs | 200 ++++++++++ resources/config.zip | Bin 171222 -> 171392 bytes resources/config/common/tls.toml | 9 + resources/config/jmap/listener.toml | 7 +- tests/resources/docker/Docker.pebble | 28 ++ .../docker/docker-compose-pebble.yaml | 28 ++ tests/src/imap/mod.rs | 15 +- tests/src/jmap/mod.rs | 15 +- tests/src/jmap/push_subscription.rs | 6 +- tests/src/smtp/config.rs | 11 +- tests/src/smtp/session.rs | 4 +- 43 files changed, 2285 insertions(+), 464 deletions(-) create mode 100644 crates/utils/src/acme/cache.rs create mode 100644 crates/utils/src/acme/directory.rs create mode 100644 crates/utils/src/acme/jose.rs create mode 100644 crates/utils/src/acme/mod.rs create mode 100644 crates/utils/src/acme/order.rs create mode 100644 crates/utils/src/acme/resolver.rs delete mode 100644 crates/utils/src/config/certificate.rs create mode 100644 crates/utils/src/config/tls.rs create mode 100644 crates/utils/src/listener/tls.rs create mode 100644 tests/resources/docker/Docker.pebble create mode 100644 tests/resources/docker/docker-compose-pebble.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 001d28ce..6c83363c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. This projec ## [0.5.2] - 2024-01-xx ## Added +- ACME support for automatic TLS certificate generation and renewal. ### Changed diff --git a/Cargo.lock b/Cargo.lock index 9901a578..08b043bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,9 +173,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.77" +version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9d19de80eff169429ac1e9f48fffb163916b448a44e8e046186232046d9e1f9" +checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" + +[[package]] +name = "arc-swap" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" [[package]] name = "argon2" @@ -270,7 +276,7 @@ checksum = "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -292,18 +298,18 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] name = "async-trait" -version = "0.1.75" +version = "0.1.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf6721fb0140e4f897002dd086c06f6c27775df19cfe1fccb21181a48fd2c98" +checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -485,7 +491,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.43", + "syn 2.0.47", "which", ] @@ -506,7 +512,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -648,7 +654,7 @@ dependencies = [ "proc-macro-crate 2.0.0", "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", "syn_derive", ] @@ -863,9 +869,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c688fc74432808e3eb684cae8830a86be1d66a2bd58e1f248ed0960a590baf6f" +checksum = "67523a3b4be3ce1989d607a828d036249522dd9c1c8de7f4dd2dae43a37369d1" dependencies = [ "glob", "libc", @@ -903,7 +909,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -1188,7 +1194,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -1236,7 +1242,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -1258,7 +1264,7 @@ checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" dependencies = [ "darling_core 0.20.3", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -1355,9 +1361,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eb30d70a07a3b04884d2677f06bec33509dc67ca60d92949e5535352d3191dc" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", "serde", @@ -1496,7 +1502,7 @@ checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -1698,7 +1704,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -1838,9 +1844,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form-data" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8068ad05855697798ecc31d03463899fce290d338ea31644bd6b4ca6d215883" +checksum = "cfaaa4f36921bef661ea6e0ee5453db203a86263c6984be5616eee4e72be68e7" dependencies = [ "bytes", "http 1.0.0", @@ -1899,7 +1905,7 @@ checksum = "83c8d52fe8b46ab822b4decdcc0d6d85aeedfc98f0d52ba2bd4aec4a97807516" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", "try_map", ] @@ -1937,7 +1943,7 @@ checksum = "b0fa992f1656e1707946bbba340ad244f0814009ef8c0118eb7b658395f19a2e" dependencies = [ "frunk_proc_macro_helpers", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -1949,7 +1955,7 @@ dependencies = [ "frunk_core", "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -1961,7 +1967,7 @@ dependencies = [ "frunk_core", "frunk_proc_macro_helpers", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -2026,7 +2032,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -2481,9 +2487,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.58" +version = "0.1.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +checksum = "b6a67363e2aa4443928ce15e57ebae94fd8949958fd1223c4cfc0cd473ad7539" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2945,12 +2951,12 @@ checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" [[package]] name = "libloading" -version = "0.7.4" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +checksum = "c571b676ddfc9a8c12f1f3d3085a7b163966a8fd8098a90640953ce5f6170161" dependencies = [ "cfg-if", - "winapi", + "windows-sys 0.48.0", ] [[package]] @@ -3314,7 +3320,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", "termcolor", "thiserror", ] @@ -3591,7 +3597,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -3902,7 +3908,7 @@ dependencies = [ "phf_shared 0.11.2", "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -3940,7 +3946,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -4055,12 +4061,12 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "prettyplease" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d" +checksum = "a41cf62165e97c7f814d2221421dbb9afcbcdb0a88068e5ea206e19951c2cbb5" dependencies = [ "proc-macro2", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -4141,9 +4147,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.71" +version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75cb1540fadbd5b8fbccc4dddad2734eba435053f725621c070711a14bb5f4b8" +checksum = "907a61bd0f64c2f29cd1cf1dc34d05176426a3f504a78010f08416ddb7b13708" dependencies = [ "unicode-ident", ] @@ -4233,9 +4239,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.33" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -4363,6 +4369,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d918c80c5a4c7560db726763020bd16db179e4d5b828078842274a443addb5d" +dependencies = [ + "pem", + "ring 0.17.7", + "time", + "yasna", +] + [[package]] name = "redis" version = "0.24.0" @@ -4781,7 +4799,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" dependencies = [ - "semver 1.0.20", + "semver 1.0.21", ] [[package]] @@ -4839,7 +4857,7 @@ dependencies = [ "log", "ring 0.17.7", "rustls-pki-types", - "rustls-webpki 0.102.0", + "rustls-webpki 0.102.1", "subtle", "zeroize", ] @@ -4903,9 +4921,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.102.0" +version = "0.102.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de2635c8bc2b88d367767c5de8ea1d8db9af3f6219eba28442242d9ab81d1b89" +checksum = "ef4ca26037c909dedb327b48c3327d0ba91d3dd3c4e05dad328f210ffb68e95b" dependencies = [ "ring 0.17.7", "rustls-pki-types", @@ -5039,9 +5057,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" +checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0" [[package]] name = "semver-parser" @@ -5105,38 +5123,38 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.193" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" +checksum = "0b114498256798c94a0689e1a15fec6005dee8ac1f41de56404b67afc2a4b773" dependencies = [ "serde_derive", ] [[package]] name = "serde_bytes" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb1879ea93538b78549031e2d54da3e901fd7e75f2e4dc758d760937b123d10" +checksum = "8b8497c313fd43ab992087548117643f6fcd935cbf36f176ffda0aacf9591734" dependencies = [ "serde", ] [[package]] name = "serde_derive" -version = "1.0.193" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" +checksum = "a3385e45322e8f9931410f01b3031ec534c3947d0e94c18049af4d9f9907d4e0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] name = "serde_json" -version = "1.0.108" +version = "1.0.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +checksum = "6fbd975230bada99c8bb618e0c365c2eefa219158d5c6c29610fd09ff1833257" dependencies = [ "itoa", "ryu", @@ -5199,7 +5217,7 @@ checksum = "91d129178576168c589c9ec973feedf7d3126c01ac2bf08795109aa35b69fb8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -5408,9 +5426,9 @@ dependencies = [ [[package]] name = "smtp-proto" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f7278d054ed6844babe3d760d03ccae24e26207c3c27b68d8385a03d3324619" +checksum = "20b37ae016fedcac2174dd0e3029870154830a4d3fb10f533317f2604e72b343" [[package]] name = "snafu" @@ -5514,6 +5532,7 @@ dependencies = [ "openssl", "pwhash", "rand", + "rcgen", "reqwest", "rpassword", "tar", @@ -5630,9 +5649,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.43" +version = "2.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee659fb5f3d355364e1f3e5bc10fb82068efbf824a1e9d1c9504244a6469ad53" +checksum = "1726efe18f42ae774cc644f330953a5e7b3c3003d3edcecf18850fe9d4dd9afb" dependencies = [ "proc-macro2", "quote", @@ -5648,7 +5667,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -5791,22 +5810,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.52" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a48fd946b02c0a526b2e9481c8e2a17755e47039164a86c4070446e3a4614d" +checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.52" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7fbe9b594d6568a6a1443250a7e67d80b74e1e96f6d1715e1e21cc1888291d3" +checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -5919,7 +5938,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -6129,7 +6148,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -6388,20 +6407,29 @@ name = "utils" version = "0.5.1" dependencies = [ "ahash 0.8.7", + "arc-swap", + "base64 0.21.5", "chrono", "dashmap", + "futures", "mail-auth", "mail-send", "opentelemetry", "opentelemetry-otlp", "opentelemetry-semantic-conventions", "opentelemetry_sdk", + "parking_lot", + "pem", "privdrop", "rand", + "rcgen", + "reqwest", + "ring 0.17.7", "rustls 0.22.1", "rustls-pemfile 2.0.0", "rustls-pki-types", "serde", + "serde_json", "smtp-proto", "tokio", "tokio-rustls 0.25.0", @@ -6411,6 +6439,7 @@ dependencies = [ "tracing-opentelemetry", "tracing-subscriber", "webpki-roots 0.26.0", + "x509-parser", ] [[package]] @@ -6482,7 +6511,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", "wasm-bindgen-shared", ] @@ -6516,7 +6545,7 @@ checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -6674,11 +6703,11 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" -version = "0.51.1" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.48.5", + "windows-targets 0.52.0", ] [[package]] @@ -6881,9 +6910,9 @@ checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" [[package]] name = "winnow" -version = "0.5.31" +version = "0.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a4882e6b134d6c28953a387571f1acdd3496830d5e36c5e3a1075580ea641c" +checksum = "8434aeec7b290e8da5c3f0d628cb0eac6cabcb31d14bb74f779a08109a5914d6" dependencies = [ "memchr", ] @@ -6958,6 +6987,15 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53be06678ed9e83edb1745eb72efc0bbcd7b5c3c35711a860906aed827a13d61" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "zerocopy" version = "0.7.32" @@ -6975,7 +7013,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] @@ -6995,7 +7033,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.43", + "syn 2.0.47", ] [[package]] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 0a317db3..82e5adb8 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -69,7 +69,7 @@ async fn main() -> std::io::Result<()> { Commands::Export(command) => { command.exec(client).await; } - Commands::Database(command) => command.exec(client).await, + Commands::Server(command) => command.exec(client).await, Commands::Account(command) => command.exec(client).await, Commands::Domain(command) => command.exec(client).await, Commands::List(command) => command.exec(client).await, diff --git a/crates/cli/src/modules/cli.rs b/crates/cli/src/modules/cli.rs index c8dee697..c48d1ec8 100644 --- a/crates/cli/src/modules/cli.rs +++ b/crates/cli/src/modules/cli.rs @@ -71,7 +71,7 @@ pub enum Commands { /// Manage JMAP database #[clap(subcommand)] - Database(DatabaseCommands), + Server(ServerCommands), /// Manage SMTP message queue #[clap(subcommand)] @@ -399,9 +399,11 @@ pub enum ExportCommands { } #[derive(Subcommand)] -pub enum DatabaseCommands { +pub enum ServerCommands { /// Perform database maintenance - Maintenance {}, + DatabaseMaintenance {}, + /// Reload TLS certificates + ReloadCertificates {}, } #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] diff --git a/crates/cli/src/modules/database.rs b/crates/cli/src/modules/database.rs index 42c4c769..7317951d 100644 --- a/crates/cli/src/modules/database.rs +++ b/crates/cli/src/modules/database.rs @@ -24,17 +24,23 @@ use reqwest::Method; use serde_json::Value; -use super::cli::{Client, DatabaseCommands}; +use super::cli::{Client, ServerCommands}; -impl DatabaseCommands { +impl ServerCommands { pub async fn exec(self, client: Client) { match self { - DatabaseCommands::Maintenance {} => { + ServerCommands::DatabaseMaintenance {} => { client .http_request::(Method::GET, "/admin/store/maintenance", None) .await; eprintln!("Success."); } + ServerCommands::ReloadCertificates {} => { + client + .http_request::(Method::GET, "/admin/certificates/reload", None) + .await; + eprintln!("Success."); + } } } } diff --git a/crates/imap/src/core/session.rs b/crates/imap/src/core/session.rs index ab78ac60..7c8930c4 100644 --- a/crates/imap/src/core/session.rs +++ b/crates/imap/src/core/session.rs @@ -142,7 +142,7 @@ impl Session { } pub async fn handle_conn(mut self) { - if self.handle_conn_().await && self.instance.tls_acceptor.is_some() { + if self.handle_conn_().await && self.instance.acceptor.is_tls() { if let Ok(session) = self.into_tls().await { session.handle_conn().await; } diff --git a/crates/install/Cargo.toml b/crates/install/Cargo.toml index d3eec77c..f7fb38e8 100644 --- a/crates/install/Cargo.toml +++ b/crates/install/Cargo.toml @@ -21,6 +21,7 @@ pwhash = "1.0.0" rand = "0.8.5" clap = { version = "4.1.6", features = ["derive"] } zip-extract = "0.1.2" +rcgen = "0.12" [target.'cfg(not(target_env = "msvc"))'.dependencies] libc = "0.2.147" diff --git a/crates/install/src/main.rs b/crates/install/src/main.rs index d7eeb9a5..1b815f97 100644 --- a/crates/install/src/main.rs +++ b/crates/install/src/main.rs @@ -31,9 +31,10 @@ use std::{ use base64::{engine::general_purpose, Engine}; use clap::{Parser, ValueEnum}; -use dialoguer::{console::Term, theme::ColorfulTheme, Input, Select}; +use dialoguer::{console::Term, theme::ColorfulTheme, Confirm, Input, Select}; use openssl::rsa::Rsa; use rand::{distributions::Alphanumeric, thread_rng, Rng}; +use rcgen::generate_simple_self_signed; const CONFIG_URL: &str = "https://get.stalw.art/resources/config.zip"; @@ -468,35 +469,34 @@ fn main() -> std::io::Result<()> { .trim() .to_lowercase(); - // Obtain TLS certificate path - let (cert_path, pk_path) = if !args.docker { - #[cfg(not(target_env = "msvc"))] - let cert_base_path = format!("/etc/letsencrypt/live/{}/", hostname); - #[cfg(target_env = "msvc")] - let cert_base_path = format!("C:\\Program Files\\Letsencrypt\\live\\{}\\", hostname); + // Obtain TLS configuration + let is_acme = Confirm::new() + .with_prompt(&format!("Do you want the TLS certificates for {hostname} to be obtained automatically from Let's Encrypt using ACME?")) + .interact() + .unwrap(); + + let (cert_path, pk_path) = { + let base_path = base_path.join("etc").join("certs").join(&hostname); + + // Create directories + fs::create_dir_all(&base_path)?; + let cert_path = base_path.join("fullchain.pem"); + let pk_path = base_path.join("privkey.pem"); + + // Build self-signed cert + let cert = generate_simple_self_signed(vec![hostname.to_string()]).unwrap_or_else(|err| { + panic!("Failed to generate self-signed certificate for {hostname}: {err}",) + }); + std::fs::write( + &cert_path, + cert.serialize_pem() + .unwrap_or_else(|err| panic!("Failed to write certificate for {hostname}: {err}",)), + )?; + std::fs::write(&pk_path, cert.serialize_private_key_pem())?; ( - input( - &format!("Where is the TLS certificate for '{hostname}' located?"), - &format!("{cert_base_path}fullchain.pem"), - file_exists, - )?, - input( - &format!("Where is the TLS private key for '{hostname}' located?"), - &format!("{cert_base_path}privkey.pem"), - file_exists, - )?, - ) - } else { - // Create directories - fs::create_dir_all(base_path.join("etc").join("certs").join(&hostname))?; - ( - format!( - "{}/etc/certs/{}/fullchain.pem", - base_path.display(), - hostname - ), - format!("{}/etc/certs/{}/privkey.pem", base_path.display(), hostname), + cert_path.to_str().unwrap().to_string(), + pk_path.to_str().unwrap().to_string(), ) }; @@ -526,13 +526,24 @@ fn main() -> std::io::Result<()> { ("__HOST__", &hostname), ], ); - sed( - cfg_path.join("common").join("tls.toml"), - &[("__CERT_PATH__", &cert_path), ("__PK_PATH__", &pk_path)], - ); + if is_acme { + sed( + cfg_path.join("common").join("tls.toml"), + &[ + ("certificate = \"default\"", "#certificate = \"default\""), + ("#acme =", "acme ="), + ("__CERT_PATH__", &cert_path), + ("__PK_PATH__", &pk_path), + ], + ); + } else { + sed( + cfg_path.join("common").join("tls.toml"), + &[("__CERT_PATH__", &cert_path), ("__PK_PATH__", &pk_path)], + ); + } // Write service file - if !args.docker { // Change permissions #[cfg(not(target_env = "msvc"))] @@ -633,13 +644,22 @@ fn main() -> std::io::Result<()> { } } + if is_acme { + eprintln!( + "\nšŸ›”ļø Ensure that port 443 (HTTPS) on {hostname} is open and accessible from the internet to successfully obtain your ACME TLS certificate.", + ); + } else { + eprintln!( + "\nšŸ›”ļø Self-signed certificates have been generated under {}, you'll need to replace them with your own certificates to enable TLS.", base_path.join("etc").join("certs").join(&hostname).display() + ); + } if is_internal { eprintln!( "\nšŸ”‘ The administrator account is 'admin' and the password can be found in the log files at {}/logs.", base_path.display() ); } - eprintln!("\nšŸŽ‰ Installation completed!\n\nāœ… {dkim_instructions}\n"); + eprintln!("\nāœ… {dkim_instructions}\nšŸŽ‰ Installation completed!\n"); Ok(()) } @@ -749,15 +769,6 @@ fn dir_create_if_missing(path: &String) -> Result<(), String> { } } -fn file_exists(path: &String) -> Result<(), String> { - let path = Path::new(path); - if path.is_file() { - Ok(()) - } else { - Err(format!("File {} does not exist", path.display())) - } -} - #[allow(clippy::ptr_arg)] fn not_empty(value: &String) -> Result<(), String> { if value.trim().is_empty() { diff --git a/crates/jmap/src/api/admin.rs b/crates/jmap/src/api/admin.rs index e75dd45d..a5cc8823 100644 --- a/crates/jmap/src/api/admin.rs +++ b/crates/jmap/src/api/admin.rs @@ -30,7 +30,7 @@ use hyper::{body::Bytes, Method, StatusCode}; use jmap_proto::error::request::RequestError; use serde_json::json; -use crate::JMAP; +use crate::{services::housekeeper, JMAP}; use super::{http::ToHttpResponse, HttpRequest, JsonResponse}; @@ -299,6 +299,17 @@ impl JMAP { .into_http_response(), } } + ("certificates", Some("reload"), &Method::GET) => { + let _ = self + .housekeeper_tx + .send(housekeeper::Event::ReloadCertificates) + .await; + + JsonResponse::new(json!({ + "data": [], + })) + .into_http_response() + } (path_1 @ ("queue" | "report"), Some(path_2), &Method::GET) => { self.smtp .handle_manage_request(req.uri(), req.method(), path_1, path_2) diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index da4928c0..69517ca5 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -42,7 +42,7 @@ use tokio::{ io::{AsyncRead, AsyncWrite}, net::TcpStream, }; -use utils::listener::{ServerInstance, SessionData, SessionManager}; +use utils::listener::{ServerInstance, SessionData, SessionManager, TcpAcceptorResult}; use crate::{ auth::{oauth::OAuthMetadata, AccessToken}, @@ -291,40 +291,45 @@ pub async fn parse_jmap_request( } impl SessionManager for JmapSessionManager { - fn spawn(&self, session: SessionData) { + fn spawn(&self, mut session: SessionData) { let jmap = self.inner.clone(); tokio::spawn(async move { - if let Some(tls_acceptor) = &session.instance.tls_acceptor { - let span = session.span; - match tls_acceptor.accept(session.stream).await { - Ok(stream) => { - handle_request( - jmap, - SessionData { - stream, - local_ip: session.local_ip, - remote_ip: session.remote_ip, - remote_port: session.remote_port, - span, - in_flight: session.in_flight, - instance: session.instance, - }, - ) - .await; - } - Err(err) => { - tracing::debug!( - parent: &span, - context = "tls", - event = "error", - "Failed to accept TLS connection: {}", - err - ); + match session.instance.acceptor.accept(session.stream).await { + TcpAcceptorResult::Tls(accept) => { + let span = session.span; + match accept.await { + Ok(stream) => { + handle_request( + jmap, + SessionData { + stream, + local_ip: session.local_ip, + remote_ip: session.remote_ip, + remote_port: session.remote_port, + span, + in_flight: session.in_flight, + instance: session.instance, + }, + ) + .await; + } + Err(err) => { + tracing::debug!( + parent: &span, + context = "tls", + event = "error", + "Failed to accept TLS connection: {}", + err + ); + } } } - } else { - handle_request(jmap, session).await; + TcpAcceptorResult::Plain(stream) => { + session.stream = stream; + handle_request(jmap, session).await; + } + TcpAcceptorResult::Close => (), } }); } diff --git a/crates/jmap/src/lib.rs b/crates/jmap/src/lib.rs index eb49f8b8..db79ec32 100644 --- a/crates/jmap/src/lib.rs +++ b/crates/jmap/src/lib.rs @@ -60,6 +60,7 @@ use tokio::sync::mpsc; use utils::{ config::Rate, ipc::DeliveryEvent, + listener::tls::Certificate, map::ttl_dashmap::{TtlDashMap, TtlMap}, snowflake::SnowflakeIdGenerator, UnwrapFailure, @@ -186,6 +187,7 @@ impl JMAP { config: &utils::config::Config, stores: &Stores, directories: &Directories, + certificates: Vec>, delivery_rx: mpsc::Receiver, smtp: Arc, ) -> Result, String> { @@ -420,7 +422,7 @@ impl JMAP { spawn_state_manager(jmap_server.clone(), config, state_rx); // Spawn housekeeper - spawn_housekeeper(jmap_server.clone(), config, housekeeper_rx); + spawn_housekeeper(jmap_server.clone(), config, certificates, housekeeper_rx); Ok(jmap_server) } diff --git a/crates/jmap/src/services/housekeeper.rs b/crates/jmap/src/services/housekeeper.rs index 6776fe22..63977fa5 100644 --- a/crates/jmap/src/services/housekeeper.rs +++ b/crates/jmap/src/services/housekeeper.rs @@ -26,6 +26,7 @@ use std::sync::Arc; use tokio::sync::mpsc; use utils::{ config::{cron::SimpleCron, Config}, + listener::tls::Certificate, map::ttl_dashmap::TtlMap, UnwrapFailure, }; @@ -36,6 +37,7 @@ use super::IPC_CHANNEL_BUFFER; pub enum Event { PurgeSessions, + ReloadCertificates, IndexStart, IndexDone, #[cfg(feature = "test_mode")] @@ -43,7 +45,12 @@ pub enum Event { Exit, } -pub fn spawn_housekeeper(core: Arc, settings: &Config, mut rx: mpsc::Receiver) { +pub fn spawn_housekeeper( + core: Arc, + settings: &Config, + certificates: Vec>, + mut rx: mpsc::Receiver, +) { let purge_cache = settings .property_or_static::("jmap.session.purge.frequency", "15 * *") .failed("Initialize housekeeper"); @@ -69,6 +76,32 @@ pub fn spawn_housekeeper(core: Arc, settings: &Config, mut rx: mpsc::Recei Event::PurgeSessions => { do_purge = true; } + Event::ReloadCertificates => { + let certificates = certificates.clone(); + tokio::spawn(async move { + for cert in certificates { + match cert.reload().await { + Ok(_) => { + tracing::info!( + context = "tls", + event = "reload", + path = cert.path[0].to_string_lossy().as_ref(), + "Reloaded certificate." + ); + } + Err(err) => { + tracing::error!( + context = "tls", + event = "error", + path = cert.path[0].to_string_lossy().as_ref(), + error = ?err, + "Failed to reload certificate." + ); + } + } + } + }); + } Event::IndexStart => { if !index_busy { index_busy = true; @@ -104,7 +137,9 @@ pub fn spawn_housekeeper(core: Arc, settings: &Config, mut rx: mpsc::Recei tracing::debug!("Housekeeper task exiting."); return; } - Err(_) => (), + Err(_) => { + do_purge = true; + } } if do_purge { diff --git a/crates/main/src/main.rs b/crates/main/src/main.rs index 718cf2d3..640a4cf2 100644 --- a/crates/main/src/main.rs +++ b/crates/main/src/main.rs @@ -57,7 +57,7 @@ async fn main() -> std::io::Result<()> { .failed("Failed to enable tracing"); // Bind ports and drop privileges - let servers = config.parse_servers().failed("Invalid configuration"); + let mut servers = config.parse_servers().failed("Invalid configuration"); servers.bind(&config); // Parse stores and directories @@ -80,9 +80,16 @@ async fn main() -> std::io::Result<()> { let smtp = SMTP::init(&config, &servers, &stores, &directory, delivery_tx) .await .failed("Invalid configuration file"); - let jmap = JMAP::init(&config, &stores, &directory, delivery_rx, smtp.clone()) - .await - .failed("Invalid configuration file"); + let jmap = JMAP::init( + &config, + &stores, + &directory, + std::mem::take(&mut servers.certificates), + delivery_rx, + smtp.clone(), + ) + .await + .failed("Invalid configuration file"); let imap = IMAP::init(&config) .await .failed("Invalid configuration file"); diff --git a/crates/managesieve/src/core/session.rs b/crates/managesieve/src/core/session.rs index 7e347de5..715aeefd 100644 --- a/crates/managesieve/src/core/session.rs +++ b/crates/managesieve/src/core/session.rs @@ -164,7 +164,7 @@ impl Session { } pub async fn handle_conn(mut self) { - if self.handle_conn_().await && self.instance.tls_acceptor.is_some() { + if self.handle_conn_().await && self.instance.acceptor.is_tls() { if let Ok(session) = self.into_tls().await { session.handle_conn().await; } diff --git a/crates/smtp/src/core/management.rs b/crates/smtp/src/core/management.rs index 3580e280..c2da73aa 100644 --- a/crates/smtp/src/core/management.rs +++ b/crates/smtp/src/core/management.rs @@ -41,7 +41,7 @@ use tokio::{ sync::oneshot, }; -use utils::listener::{limiter::InFlight, SessionManager}; +use utils::listener::{limiter::InFlight, SessionManager, TcpAcceptorResult}; use crate::{ queue::{self, instant_to_timestamp, InstantFromTimestamp, QueueId, Status}, @@ -160,8 +160,8 @@ impl SessionManager for SmtpAdminSessionManager { fn spawn(&self, session: utils::listener::SessionData) { let core = self.inner.clone(); tokio::spawn(async move { - if let Some(tls_acceptor) = &session.instance.tls_acceptor { - match tls_acceptor.accept(session.stream).await { + match session.instance.acceptor.accept(session.stream).await { + TcpAcceptorResult::Tls(accept) => match accept.await { Ok(stream) => { handle_request(stream, core, session.remote_ip, session.in_flight).await; } @@ -174,9 +174,11 @@ impl SessionManager for SmtpAdminSessionManager { err ); } + }, + TcpAcceptorResult::Plain(stream) => { + handle_request(stream, core, session.remote_ip, session.in_flight).await; } - } else { - handle_request(session.stream, core, session.remote_ip, session.in_flight).await; + TcpAcceptorResult::Close => (), } }); } diff --git a/crates/smtp/src/core/mod.rs b/crates/smtp/src/core/mod.rs index 4a3eecbe..62e14923 100644 --- a/crates/smtp/src/core/mod.rs +++ b/crates/smtp/src/core/mod.rs @@ -49,7 +49,7 @@ use tokio_rustls::TlsConnector; use tracing::Span; use utils::{ ipc::DeliveryEvent, - listener::{limiter::InFlight, ServerInstance}, + listener::{limiter::InFlight, ServerInstance, TcpAcceptor}, }; use crate::{ @@ -481,7 +481,7 @@ static ref SIEVE: Arc = Arc::new(utils::listener::ServerInstance protocol: utils::config::ServerProtocol::Lmtp, hostname: "localhost".to_string(), data: "localhost".to_string(), - tls_acceptor: None, + acceptor: TcpAcceptor::Plain, is_tls_implicit: true, limiter: utils::listener::limiter::ConcurrencyLimiter::new(0), shutdown_rx: tokio::sync::watch::channel(false).1, diff --git a/crates/smtp/src/inbound/spawn.rs b/crates/smtp/src/inbound/spawn.rs index ef4a3a6a..2f01519f 100644 --- a/crates/smtp/src/inbound/spawn.rs +++ b/crates/smtp/src/inbound/spawn.rs @@ -96,7 +96,7 @@ impl Session { } pub async fn handle_conn(mut self) { - if self.handle_conn_().await && self.instance.tls_acceptor.is_some() { + if self.handle_conn_().await && self.instance.acceptor.is_tls() { if let Ok(session) = self.into_tls().await { session.handle_conn().await; } diff --git a/crates/store/src/backend/fs/mod.rs b/crates/store/src/backend/fs/mod.rs index a0298b0f..118bc41c 100644 --- a/crates/store/src/backend/fs/mod.rs +++ b/crates/store/src/backend/fs/mod.rs @@ -40,7 +40,7 @@ pub struct FsStore { impl FsStore { pub async fn open(config: &Config, prefix: impl AsKey) -> crate::Result { let prefix = prefix.as_key(); - let path = config.property_require::((&prefix, "path"))?; + let path = PathBuf::from(config.value_require((&prefix, "path"))?); if !path.exists() { fs::create_dir_all(&path).await.map_err(|e| { crate::Error::InternalError(format!( diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index f30dad05..5b76b8eb 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -27,6 +27,16 @@ ahash = { version = "0.8" } chrono = "0.4" rand = "0.8.5" webpki-roots = { version = "0.26"} +ring = { version = "0.17" } +base64 = "0.21" +serde_json = "1.0" +rcgen = "0.12" +reqwest = { version = "0.11", default-features = false, features = ["rustls-tls-webpki-roots"]} +x509-parser = "0.15.0" +pem = "3.0" +parking_lot = "0.12" +arc-swap = "1.6.0" +futures = "0.3" [target.'cfg(unix)'.dependencies] privdrop = "0.5.3" diff --git a/crates/utils/src/acme/cache.rs b/crates/utils/src/acme/cache.rs new file mode 100644 index 00000000..db8fce53 --- /dev/null +++ b/crates/utils/src/acme/cache.rs @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::{io::ErrorKind, path::PathBuf}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use ring::digest::{Context, SHA512}; + +use super::{AcmeError, AcmeManager}; + +impl AcmeManager { + pub(crate) async fn load_cert(&self) -> Result>, AcmeError> { + self.read_if_exists("cert", self.domains.as_slice()) + .await + .map_err(AcmeError::CertCacheLoad) + } + + pub(crate) async fn store_cert(&self, cert: &[u8]) -> Result<(), AcmeError> { + self.write("cert", self.domains.as_slice(), cert) + .await + .map_err(AcmeError::CertCacheStore) + } + + pub(crate) async fn load_account(&self) -> Result>, AcmeError> { + self.read_if_exists("key", self.contact.as_slice()) + .await + .map_err(AcmeError::AccountCacheLoad) + } + + pub(crate) async fn store_account(&self, account: &[u8]) -> Result<(), AcmeError> { + self.write("key", self.contact.as_slice(), account) + .await + .map_err(AcmeError::AccountCacheStore) + } + + async fn read_if_exists( + &self, + class: &str, + items: &[String], + ) -> Result>, std::io::Error> { + match tokio::fs::read(self.build_filename(class, items)).await { + Ok(content) => Ok(Some(content)), + Err(err) => match err.kind() { + ErrorKind::NotFound => Ok(None), + _ => Err(err), + }, + } + } + + async fn write( + &self, + class: &str, + items: &[String], + contents: impl AsRef<[u8]>, + ) -> Result<(), std::io::Error> { + tokio::fs::create_dir_all(&self.cache_path).await?; + tokio::fs::write(self.build_filename(class, items), contents.as_ref()).await + } + + fn build_filename(&self, class: &str, items: &[String]) -> PathBuf { + let mut ctx = Context::new(&SHA512); + for el in items { + ctx.update(el.as_ref()); + ctx.update(&[0]) + } + ctx.update(self.directory_url.as_bytes()); + + self.cache_path.join(format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(ctx.finish()), + class + )) + } +} diff --git a/crates/utils/src/acme/directory.rs b/crates/utils/src/acme/directory.rs new file mode 100644 index 00000000..88e1f6b3 --- /dev/null +++ b/crates/utils/src/acme/directory.rs @@ -0,0 +1,364 @@ +// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0. + +use std::time::Duration; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use rcgen::{Certificate, CustomExtension, PKCS_ECDSA_P256_SHA256}; +use reqwest::header::{ToStrError, CONTENT_TYPE}; +use reqwest::{Method, Response, StatusCode}; +use ring::error::{KeyRejected, Unspecified}; +use ring::rand::SystemRandom; +use ring::signature::{EcdsaKeyPair, EcdsaSigningAlgorithm, ECDSA_P256_SHA256_FIXED_SIGNING}; +use rustls::crypto::ring::sign::any_ecdsa_type; +use rustls::sign::CertifiedKey; +use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use super::jose::{key_authorization_sha256, sign, JoseError}; + +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 key_pair: EcdsaKeyPair, + pub directory: Directory, + pub kid: String, +} + +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<'a, S, I>(directory: Directory, contact: I) -> Result + where + S: AsRef + 'a, + I: IntoIterator, + { + Self::create_with_keypair(directory, contact, &Self::generate_key_pair()).await + } + + pub async fn create_with_keypair<'a, S, I>( + directory: Directory, + contact: I, + key_pair: &[u8], + ) -> Result + where + S: AsRef + 'a, + I: IntoIterator, + { + let key_pair = EcdsaKeyPair::from_pkcs8(ALG, key_pair, &SystemRandom::new())?; + let contact: Vec<&'a str> = contact.into_iter().map(AsRef::::as_ref).collect(); + let payload = json!({ + "termsOfServiceAgreed": true, + "contact": contact, + }) + .to_string(); + 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 { + key_pair, + kid, + directory, + }) + } + + async fn request( + &self, + url: impl AsRef, + payload: &str, + ) -> Result<(Option, String), DirectoryError> { + let body = sign( + &self.key_pair, + Some(&self.kid), + self.directory.nonce().await?, + url.as_ref(), + payload, + )?; + let response = https(url.as_ref(), Method::POST, Some(body)).await?; + let location = get_header(&response, "Location").ok(); + let body = response.text().await?; + Ok((location, body)) + } + + pub async fn new_order(&self, domains: Vec) -> Result<(String, Order), DirectoryError> { + let domains: Vec = domains.into_iter().map(Identifier::Dns).collect(); + let payload = format!("{{\"identifiers\":{}}}", serde_json::to_string(&domains)?); + let response = self.request(&self.directory.new_order, &payload).await?; + let url = response + .0 + .ok_or(DirectoryError::MissingHeader("Location"))?; + let order = serde_json::from_str(&response.1)?; + Ok((url, order)) + } + + pub async fn auth(&self, url: impl AsRef) -> Result { + let response = self.request(url, "").await?; + serde_json::from_str(&response.1).map_err(Into::into) + } + + pub async fn challenge(&self, url: impl AsRef) -> Result<(), DirectoryError> { + self.request(&url, "{}").await.map(|_| ()) + } + + pub async fn order(&self, url: impl AsRef) -> Result { + let response = self.request(&url, "").await?; + serde_json::from_str(&response.1).map_err(Into::into) + } + + pub async fn finalize( + &self, + url: impl AsRef, + csr: Vec, + ) -> Result { + 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(Into::into) + } + + pub async fn certificate(&self, url: impl AsRef) -> Result { + Ok(self.request(&url, "").await?.1) + } + + pub fn tls_alpn_01<'a>( + &self, + challenges: &'a [Challenge], + domain: String, + ) -> Result<(&'a Challenge, CertifiedKey), DirectoryError> { + let challenge = challenges + .iter() + .find(|c| c.typ == ChallengeType::TlsAlpn01); + let challenge = match challenge { + Some(challenge) => challenge, + None => return Err(DirectoryError::NoTlsAlpn01Challenge), + }; + 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)?; + let pk = any_ecdsa_type(&PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( + cert.serialize_private_key_der(), + ))) + .unwrap(); + let certified_key = + CertifiedKey::new(vec![CertificateDer::from(cert.serialize_der()?)], pk); + Ok((challenge, certified_key)) + } +} + +#[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) -> Result { + Ok(serde_json::from_str( + &https(url, Method::GET, None).await?.text().await?, + )?) + } + pub async fn nonce(&self) -> Result { + get_header( + &https(&self.new_nonce.as_str(), Method::HEAD, None).await?, + "replay-nonce", + ) + } +} + +#[derive(Debug, Deserialize, Eq, PartialEq)] +pub enum ChallengeType { + #[serde(rename = "http-01")] + Http01, + #[serde(rename = "dns-01")] + Dns01, + #[serde(rename = "tls-alpn-01")] + TlsAlpn01, +} + +#[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, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AuthStatus { + Pending, + Valid, + Invalid, + Revoked, + Expired, + Deactivated, +} + +#[derive(Clone, Debug, 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, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Problem { + #[serde(rename = "type")] + pub typ: Option, + pub detail: Option, +} + +#[derive(Debug)] +pub enum DirectoryError { + Io(std::io::Error), + Rcgen(rcgen::Error), + Jose(JoseError), + Json(serde_json::Error), + HttpRequest(reqwest::Error), + HttpRequestCode { code: StatusCode, reason: String }, + HttpResponseNonStringHeader(ToStrError), + KeyRejected(KeyRejected), + Crypto(Unspecified), + MissingHeader(&'static str), + NoTlsAlpn01Challenge, +} + +async fn https( + url: impl AsRef, + method: Method, + body: Option, +) -> Result { + let url = url.as_ref(); + let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(30)); + + #[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); + + 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 { + Err(DirectoryError::HttpRequestCode { + code: response.status(), + reason: response.text().await?, + }) + } +} + +fn get_header(response: &Response, header: &'static str) -> Result { + match response.headers().get_all(header).iter().last() { + Some(value) => Ok(value.to_str()?.to_string()), + None => Err(DirectoryError::MissingHeader(header)), + } +} + +impl From for DirectoryError { + fn from(err: std::io::Error) -> Self { + Self::Io(err) + } +} + +impl From for DirectoryError { + fn from(err: rcgen::Error) -> Self { + Self::Rcgen(err) + } +} + +impl From for DirectoryError { + fn from(err: JoseError) -> Self { + Self::Jose(err) + } +} + +impl From for DirectoryError { + fn from(err: serde_json::Error) -> Self { + Self::Json(err) + } +} + +impl From for DirectoryError { + fn from(err: reqwest::Error) -> Self { + Self::HttpRequest(err) + } +} + +impl From for DirectoryError { + fn from(err: KeyRejected) -> Self { + Self::KeyRejected(err) + } +} + +impl From for DirectoryError { + fn from(err: Unspecified) -> Self { + Self::Crypto(err) + } +} + +impl From for DirectoryError { + fn from(err: ToStrError) -> Self { + Self::HttpResponseNonStringHeader(err) + } +} diff --git a/crates/utils/src/acme/jose.rs b/crates/utils/src/acme/jose.rs new file mode 100644 index 00000000..f8eb7472 --- /dev/null +++ b/crates/utils/src/acme/jose.rs @@ -0,0 +1,140 @@ +// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use ring::digest::{digest, Digest, SHA256}; +use ring::rand::SystemRandom; +use ring::signature::{EcdsaKeyPair, KeyPair}; +use serde::Serialize; + +pub(crate) fn sign( + key: &EcdsaKeyPair, + kid: Option<&str>, + nonce: String, + url: &str, + payload: &str, +) -> Result { + let jwk = match kid { + None => Some(Jwk::new(key)), + Some(_) => None, + }; + let protected = Protected::base64(jwk, kid, nonce, url)?; + let payload = URL_SAFE_NO_PAD.encode(payload); + let combined = format!("{}.{}", &protected, &payload); + let signature = key.sign(&SystemRandom::new(), combined.as_bytes())?; + let signature = URL_SAFE_NO_PAD.encode(signature.as_ref()); + let body = Body { + protected, + payload, + signature, + }; + Ok(serde_json::to_string(&body)?) +} + +pub(crate) fn key_authorization_sha256( + key: &EcdsaKeyPair, + token: &str, +) -> Result { + let jwk = Jwk::new(key); + let key_authorization = format!("{}.{}", token, jwk.thumb_sha256_base64()?); + Ok(digest(&SHA256, key_authorization.as_bytes())) +} + +#[derive(Serialize)] +struct Body { + protected: String, + payload: String, + signature: String, +} + +#[derive(Serialize)] +struct Protected<'a> { + alg: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + jwk: Option, + #[serde(skip_serializing_if = "Option::is_none")] + kid: Option<&'a str>, + nonce: String, + url: &'a str, +} + +impl<'a> Protected<'a> { + fn base64( + jwk: Option, + kid: Option<&'a str>, + nonce: String, + url: &'a str, + ) -> Result { + let protected = Self { + alg: "ES256", + jwk, + kid, + nonce, + url, + }; + let protected = serde_json::to_vec(&protected)?; + Ok(URL_SAFE_NO_PAD.encode(protected)) + } +} + +#[derive(Serialize)] +struct Jwk { + alg: &'static str, + crv: &'static str, + kty: &'static str, + #[serde(rename = "use")] + u: &'static str, + x: String, + y: String, +} + +impl Jwk { + pub(crate) fn new(key: &EcdsaKeyPair) -> Self { + let (x, y) = key.public_key().as_ref()[1..].split_at(32); + Self { + alg: "ES256", + crv: "P-256", + kty: "EC", + u: "sig", + x: URL_SAFE_NO_PAD.encode(x), + y: URL_SAFE_NO_PAD.encode(y), + } + } + pub(crate) fn thumb_sha256_base64(&self) -> Result { + let jwk_thumb = JwkThumb { + crv: self.crv, + kty: self.kty, + x: &self.x, + y: &self.y, + }; + let json = serde_json::to_vec(&jwk_thumb)?; + let hash = digest(&SHA256, &json); + Ok(URL_SAFE_NO_PAD.encode(hash)) + } +} + +#[derive(Serialize)] +struct JwkThumb<'a> { + crv: &'a str, + kty: &'a str, + x: &'a str, + y: &'a str, +} + +#[derive(Debug)] +pub enum JoseError { + Json(serde_json::Error), + Crypto(ring::error::Unspecified), +} + +impl From for JoseError { + fn from(err: serde_json::Error) -> Self { + Self::Json(err) + } +} + +impl From for JoseError { + fn from(err: ring::error::Unspecified) -> Self { + Self::Crypto(err) + } +} diff --git a/crates/utils/src/acme/mod.rs b/crates/utils/src/acme/mod.rs new file mode 100644 index 00000000..a05e23a5 --- /dev/null +++ b/crates/utils/src/acme/mod.rs @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +pub mod cache; +pub mod directory; +pub mod jose; +pub mod order; +pub mod resolver; + +use std::{ + fmt::Debug, + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::Duration, +}; + +use ahash::AHashMap; +use arc_swap::ArcSwap; +use parking_lot::Mutex; +use rustls::sign::CertifiedKey; +use tokio::sync::watch; + +use crate::config::tls::build_self_signed_cert; + +use self::{ + directory::Account, + order::{CertParseError, OrderError}, +}; + +pub struct AcmeManager { + pub(crate) directory_url: String, + pub(crate) domains: Vec, + contact: Vec, + renew_before: chrono::Duration, + cache_path: PathBuf, + account_key: ArcSwap>, + auth_keys: Mutex>>, + order_in_progress: AtomicBool, + cert: ArcSwap, +} + +#[derive(Debug)] +pub enum AcmeError { + CertCacheLoad(std::io::Error), + AccountCacheLoad(std::io::Error), + CertCacheStore(std::io::Error), + AccountCacheStore(std::io::Error), + CachedCertParse(CertParseError), + Order(OrderError), + NewCertParse(CertParseError), +} + +impl AcmeManager { + pub fn new( + directory_url: String, + domains: Vec, + contact: Vec, + renew_before: Duration, + cache_path: PathBuf, + ) -> crate::config::Result { + Ok(AcmeManager { + directory_url, + contact: contact + .into_iter() + .map(|c| { + if !c.starts_with("mailto:") { + format!("mailto:{}", c) + } else { + c + } + }) + .collect(), + renew_before: chrono::Duration::from_std(renew_before).unwrap(), + cache_path, + account_key: ArcSwap::from_pointee(Vec::new()), + auth_keys: Mutex::new(AHashMap::new()), + order_in_progress: false.into(), + cert: ArcSwap::from_pointee(build_self_signed_cert(&domains)?), + domains, + }) + } + + pub async fn init(&self) -> Result { + // Load account key from cache or generate a new one + if let Some(account_key) = self.load_account().await? { + self.account_key.store(Arc::new(account_key)); + } else { + let account_key = Account::generate_key_pair(); + self.store_account(&account_key).await?; + self.account_key.store(Arc::new(account_key)); + } + + // Load certificate from cache or request a new one + Ok(if let Some(pem) = self.load_cert().await? { + self.process_cert(pem, true).await? + } else { + Duration::from_millis(1000) + }) + } + + pub fn has_order_in_progress(&self) -> bool { + self.order_in_progress.load(Ordering::Relaxed) + } +} + +pub trait SpawnAcme { + fn spawn(self, shutdown_rx: watch::Receiver); +} + +impl SpawnAcme for Arc { + fn spawn(self, mut shutdown_rx: watch::Receiver) { + tokio::spawn(async move { + let acme = self; + let mut renew_at = match acme.init().await { + Ok(renew_at) => renew_at, + Err(err) => { + tracing::error!( + context = "acme", + event = "error", + error = ?err, + "Failed to initialize ACME certificate manager."); + + return; + } + }; + + loop { + tokio::select! { + _ = tokio::time::sleep(renew_at) => { + tracing::info!( + context = "acme", + event = "order", + domains = ?acme.domains, + "Ordering certificates."); + + match acme.renew().await { + Ok(renew_at_) => { + renew_at = renew_at_; + tracing::info!( + context = "acme", + event = "success", + domains = ?acme.domains, + next_renewal = ?renew_at, + "Certificates renewed."); + }, + Err(err) => { + tracing::error!( + context = "acme", + event = "error", + error = ?err, + "Failed to renew certificates."); + + renew_at = Duration::from_secs(3600); + }, + } + + }, + _ = shutdown_rx.changed() => { + tracing::debug!( + context = "acme", + event = "shutdown", + domains = ?acme.domains, + "ACME certificate manager shutting down."); + + break; + } + }; + } + }); + } +} + +impl Debug for AcmeManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AcmeManager") + .field("directory_url", &self.directory_url) + .field("domains", &self.domains) + .field("contact", &self.contact) + .field("cache_path", &self.cache_path) + .field("account_key", &self.account_key) + .finish() + } +} diff --git a/crates/utils/src/acme/order.rs b/crates/utils/src/acme/order.rs new file mode 100644 index 00000000..c72eacec --- /dev/null +++ b/crates/utils/src/acme/order.rs @@ -0,0 +1,300 @@ +// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0. + +use chrono::{DateTime, 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::fmt::Debug; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; +use x509_parser::parse_x509_certificate; + +use crate::acme::directory::Identifier; + +use super::directory::{Account, Auth, AuthStatus, Directory, DirectoryError, Order, OrderStatus}; +use super::jose::JoseError; +use super::{AcmeError, AcmeManager}; + +#[derive(Debug)] +pub enum OrderError { + Acme(DirectoryError), + Rcgen(rcgen::Error), + BadOrder(Order), + BadAuth(Auth), + TooManyAttemptsAuth(String), + ProcessingTimeout(Order), +} + +#[derive(Debug)] +pub enum CertParseError { + X509(x509_parser::nom::Err), + Pem(pem::PemError), + TooFewPem(usize), + InvalidPrivateKey, +} + +impl AcmeManager { + pub(crate) async fn process_cert( + &self, + pem: Vec, + cached: bool, + ) -> Result { + let (cert, validity) = match (parse_cert(&pem), cached) { + (Ok(r), _) => r, + (Err(err), cached) => { + return match cached { + true => Err(AcmeError::CachedCertParse(err)), + false => Err(AcmeError::NewCertParse(err)), + } + } + }; + + self.set_cert(Arc::new(cert)); + + let renew_at = (validity[1] - self.renew_before - Utc::now()) + .max(chrono::Duration::zero()) + .to_std() + .unwrap_or_default(); + let renewal_date = validity[1] - self.renew_before; + + tracing::info!( + context = "acme", + event = "process-cert", + valid_not_before = %validity[0], + valid_not_after = %validity[1], + renewal_date = ?renewal_date, + domains = ?self.domains, + "Loaded certificate for domains {:?}", self.domains); + + if !cached { + self.store_cert(&pem).await?; + } + + Ok(renew_at) + } + + pub async fn renew(&self) -> Result { + let mut backoff = 0; + self.order_in_progress.store(true, Ordering::Relaxed); + loop { + match self.order().await { + Ok(pem) => return self.process_cert(pem, false).await, + Err(err) if backoff < 16 => { + tracing::debug!( + context = "acme", + event = "renew-backoff", + domains = ?self.domains, + attempt = backoff, + reason = ?err, + "Failed to renew certificate, backing off for {} seconds", + 1 << backoff); + backoff = (backoff + 1).min(16); + tokio::time::sleep(Duration::from_secs(1 << backoff)).await; + } + Err(err) => return Err(AcmeError::Order(err)), + } + } + } + + async fn order(&self) -> Result, OrderError> { + let directory = Directory::discover(&self.directory_url).await?; + let account = Account::create_with_keypair( + directory, + &self.contact, + self.account_key.load().as_slice(), + ) + .await?; + + let mut params = CertificateParams::new(self.domains.clone()); + params.distinguished_name = DistinguishedName::new(); + params.alg = &PKCS_ECDSA_P256_SHA256; + let cert = rcgen::Certificate::from_params(params)?; + + let (order_url, mut order) = account.new_order(self.domains.clone()).await?; + loop { + match order.status { + OrderStatus::Pending => { + let auth_futures = order + .authorizations + .iter() + .map(|url| self.authorize(&account, url)); + try_join_all(auth_futures).await?; + tracing::info!( + context = "acme", + event = "auth-complete", + domains = ?self.domains.as_slice(), + "Completed all authorizations" + ); + order = account.order(&order_url).await?; + } + OrderStatus::Processing => { + for i in 0u64..10 { + tracing::info!( + context = "acme", + event = "processing", + domains = ?self.domains.as_slice(), + attempt = i, + "Processing order" + ); + tokio::time::sleep(Duration::from_secs(1u64 << i)).await; + order = account.order(&order_url).await?; + if order.status != OrderStatus::Processing { + break; + } + } + if order.status == OrderStatus::Processing { + return Err(OrderError::ProcessingTimeout(order)); + } + } + OrderStatus::Ready => { + tracing::info!( + context = "acme", + event = "csr-send", + domains = ?self.domains.as_slice(), + "Sending CSR" + ); + + let csr = cert.serialize_request_der()?; + order = account.finalize(order.finalize, csr).await? + } + OrderStatus::Valid { certificate } => { + tracing::info!( + context = "acme", + event = "download", + domains = ?self.domains.as_slice(), + "Downloading certificate" + ); + + let pem = [ + &cert.serialize_private_key_pem(), + "\n", + &account.certificate(certificate).await?, + ] + .concat(); + return Ok(pem.into_bytes()); + } + OrderStatus::Invalid => { + tracing::warn!( + context = "acme", + event = "error", + reason = "invalid-order", + domains = ?self.domains.as_slice(), + "Invalid order" + ); + + return Err(OrderError::BadOrder(order)); + } + } + } + } + + async fn authorize(&self, account: &Account, url: &String) -> Result<(), OrderError> { + let auth = account.auth(url).await?; + let (domain, challenge_url) = match auth.status { + AuthStatus::Pending => { + let Identifier::Dns(domain) = auth.identifier; + tracing::info!( + context = "acme", + event = "challenge", + domain = domain, + "Requesting challenge for domain {domain}" + ); + let (challenge, auth_key) = + account.tls_alpn_01(&auth.challenges, domain.clone())?; + self.set_auth_key(domain.clone(), Arc::new(auth_key)); + account.challenge(&challenge.url).await?; + (domain, challenge.url.clone()) + } + AuthStatus::Valid => return Ok(()), + _ => return Err(OrderError::BadAuth(auth)), + }; + for i in 0u64..5 { + tokio::time::sleep(Duration::from_secs(1u64 << i)).await; + let auth = account.auth(url).await?; + match auth.status { + AuthStatus::Pending => { + tracing::info!( + context = "acme", + event = "auth-pending", + domain = domain, + attempt = i, + "Authorization for domain {domain} is still pending", + ); + account.challenge(&challenge_url).await? + } + AuthStatus::Valid => return Ok(()), + _ => return Err(OrderError::BadAuth(auth)), + } + } + Err(OrderError::TooManyAttemptsAuth(domain)) + } +} + +fn parse_cert(pem: &[u8]) -> Result<(CertifiedKey, [DateTime; 2]), CertParseError> { + let mut pems = pem::parse_many(pem)?; + if pems.len() < 2 { + return Err(CertParseError::TooFewPem(pems.len())); + } + let pk = match any_ecdsa_type(&PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( + pems.remove(0).contents(), + ))) { + Ok(pk) => pk, + Err(_) => return Err(CertParseError::InvalidPrivateKey), + }; + 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() + }) + } + Err(err) => return Err(CertParseError::X509(err)), + }; + let cert = CertifiedKey::new(cert_chain, pk); + Ok((cert, validity)) +} + +impl From for OrderError { + fn from(err: DirectoryError) -> Self { + Self::Acme(err) + } +} + +impl From for OrderError { + fn from(err: rcgen::Error) -> Self { + Self::Rcgen(err) + } +} + +impl From> for CertParseError { + fn from(err: x509_parser::nom::Err) -> Self { + Self::X509(err) + } +} + +impl From for CertParseError { + fn from(err: pem::PemError) -> Self { + Self::Pem(err) + } +} + +impl From for OrderError { + fn from(err: JoseError) -> Self { + Self::Acme(DirectoryError::Jose(err)) + } +} + +impl From for AcmeError { + fn from(err: JoseError) -> Self { + Self::Order(OrderError::from(err)) + } +} diff --git a/crates/utils/src/acme/resolver.rs b/crates/utils/src/acme/resolver.rs new file mode 100644 index 00000000..58da89ce --- /dev/null +++ b/crates/utils/src/acme/resolver.rs @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::sync::{atomic::Ordering, Arc}; + +use rustls::{ + server::{ClientHello, ResolvesServerCert}, + sign::CertifiedKey, +}; + +use super::{directory::ACME_TLS_ALPN_NAME, AcmeManager}; + +impl AcmeManager { + pub(crate) fn set_cert(&self, cert: Arc) { + self.cert.store(cert); + self.order_in_progress.store(false, Ordering::Relaxed); + self.auth_keys.lock().clear(); + } + pub(crate) fn set_auth_key(&self, domain: String, cert: Arc) { + self.auth_keys.lock().insert(domain, cert); + } +} + +impl ResolvesServerCert for AcmeManager { + fn resolve(&self, client_hello: ClientHello) -> Option> { + if self.has_order_in_progress() && client_hello.is_tls_alpn_challenge() { + match client_hello.server_name() { + None => { + tracing::debug!( + context = "acme", + event = "error", + reason = "missing-sni", + "client did not supply SNI" + ); + None + } + Some(domain) => { + tracing::trace!( + context = "acme", + event = "auth-key", + domain = %domain, + "Found client supplied SNI"); + + self.auth_keys.lock().get(domain).cloned() + } + } + } else { + self.cert.load().clone().into() + } + } +} + +pub trait IsTlsAlpnChallenge { + fn is_tls_alpn_challenge(&self) -> bool; +} + +impl IsTlsAlpnChallenge for ClientHello<'_> { + fn is_tls_alpn_challenge(&self) -> bool { + self.alpn().into_iter().flatten().eq([ACME_TLS_ALPN_NAME]) + } +} diff --git a/crates/utils/src/config/certificate.rs b/crates/utils/src/config/certificate.rs deleted file mode 100644 index 2972795a..00000000 --- a/crates/utils/src/config/certificate.rs +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (c) 2023 Stalwart Labs Ltd. - * - * This file is part of Stalwart Mail Server. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of - * the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * in the LICENSE file at the top-level directory of this distribution. - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * You can be released from the requirements of the AGPLv3 license by - * purchasing a commercial license. Please contact licensing@stalw.art - * for more details. -*/ - -use std::{io::Cursor, sync::Arc}; - -use rustls::{ - server::{ClientHello, ResolvesServerCert, ResolvesServerCertUsingSni}, - sign::CertifiedKey, - version::{TLS12, TLS13}, - SupportedProtocolVersion, -}; -use rustls_pemfile::{certs, read_one, Item}; -use rustls_pki_types::{CertificateDer, PrivateKeyDer}; - -use super::Config; - -pub static TLS13_VERSION: &[&SupportedProtocolVersion] = &[&TLS13]; -pub static TLS12_VERSION: &[&SupportedProtocolVersion] = &[&TLS12]; - -#[derive(Debug)] -pub struct CertificateResolver { - pub resolver: Option, - pub default_cert: Option>, -} - -impl ResolvesServerCert for CertificateResolver { - fn resolve(&self, hello: ClientHello<'_>) -> Option> { - self.resolver - .as_ref() - .and_then(|r| r.resolve(hello)) - .or_else(|| self.default_cert.clone()) - } -} - -impl Config { - pub fn rustls_certificate(&self, cert_id: &str) -> super::Result>> { - let certs = certs(&mut Cursor::new(self.file_contents(( - "certificate", - cert_id, - "cert", - ))?)) - .collect::, _>>() - .map_err(|err| { - format!("Failed to read certificates in \"certificate.{cert_id}.cert\": {err}") - })?; - - if !certs.is_empty() { - Ok(certs) - } else { - Err(format!( - "No certificates found in \"certificate.{cert_id}.cert\"." - )) - } - } - - pub fn rustls_private_key(&self, cert_id: &str) -> super::Result> { - match read_one(&mut Cursor::new(self.file_contents(( - "certificate", - cert_id, - "private-key", - ))?)) - .map_err(|err| { - format!("Failed to read private keys in \"certificate.{cert_id}.private-key\": {err}",) - })? - .into_iter() - .next() - { - Some(Item::Pkcs8Key(key)) => Ok(PrivateKeyDer::Pkcs8(key)), - Some(Item::Pkcs1Key(key)) => Ok(PrivateKeyDer::Pkcs1(key)), - Some(Item::Sec1Key(key)) => Ok(PrivateKeyDer::Sec1(key)), - Some(_) => Err(format!( - "Unsupported private keys found in \"certificate.{cert_id}.private-key\".", - )), - None => Err(format!( - "No private keys found in \"certificate.{cert_id}.private-key\".", - )), - } - } -} diff --git a/crates/utils/src/config/listener.rs b/crates/utils/src/config/listener.rs index d1a0c56e..735657e9 100644 --- a/crates/utils/src/config/listener.rs +++ b/crates/utils/src/config/listener.rs @@ -23,6 +23,7 @@ use std::{net::SocketAddr, sync::Arc}; +use ahash::AHashMap; use rustls::{ crypto::ring::{ cipher_suite::{ @@ -32,173 +33,87 @@ use rustls::{ TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, }, default_provider, - sign::any_supported_type, }, - server::ResolvesServerCertUsingSni, - sign::CertifiedKey, + server::ResolvesServerCert, ServerConfig, SupportedCipherSuite, ALL_VERSIONS, }; use tokio::net::TcpSocket; +use tokio_rustls::TlsAcceptor; -use crate::UnwrapFailure; +use crate::{ + acme::{directory::ACME_TLS_ALPN_NAME, AcmeManager}, + listener::{ + tls::{Certificate, CertificateResolver}, + TcpAcceptor, + }, + UnwrapFailure, +}; use super::{ - certificate::{CertificateResolver, TLS12_VERSION, TLS13_VERSION}, + tls::{TLS12_VERSION, TLS13_VERSION}, utils::{AsKey, ParseKey, ParseValue}, Config, Listener, Server, ServerProtocol, Servers, }; impl Config { pub fn parse_servers(&self) -> super::Result { - let mut servers: Vec = Vec::new(); + // Parse certificates and ACME managers + let certificates = self.parse_certificates()?; + let acmes = self.parse_acmes()?; + + // Parse servers + let mut servers = Servers::default(); for (internal_id, id) in self.sub_keys("server.listener").enumerate() { - let mut server = self.parse_server(id)?; - if !servers.iter().any(|s| s.id == server.id) { + let mut server = self.parse_server(id, &certificates, &acmes)?; + if !servers.inner.iter().any(|s| s.id == server.id) { server.internal_id = internal_id as u16; - servers.push(server); + servers.inner.push(server); } else { return Err(format!("Duplicate listener id {:?}.", server.id)); } } - if !servers.is_empty() { - Ok(Servers { inner: servers }) + // Add certificates with valid paths + for (id, cert) in certificates { + if cert.path.len() == 2 { + servers.certificates.push(cert); + } else { + tracing::debug!( + context = "config", + event = "acme", + id = id, + "Certificate reloading disabled for id {id:?}", + ); + } + } + + // Add ACME managers with configured domains + for (id, acme) in acmes { + if !acme.domains.is_empty() { + servers.acme_managers.push(acme); + } else { + tracing::debug!( + context = "config", + event = "acme", + id = id, + "ACME certificate manager disabled for id {id:?}", + ); + } + } + + if !servers.inner.is_empty() { + Ok(servers) } else { Err("No server directives found in config file.".to_string()) } } - fn parse_server(&self, id: &str) -> super::Result { - // Build TLS config - let (tls, tls_implicit) = if self - .property_or_default(("server.listener", id, "tls.enable"), "server.tls.enable")? - .unwrap_or(false) - { - // Parse protocol versions - let mut tls_v2 = false; - let mut tls_v3 = false; - for (key, protocol) in self.values_or_default( - ("server.listener", id, "tls.protocols"), - "server.tls.protocols", - ) { - match protocol { - "TLSv1.2" | "0x0303" => tls_v2 = true, - "TLSv1.3" | "0x0304" => tls_v3 = true, - protocol => { - return Err(format!( - "Unsupported TLS protocol {protocol:?} found in key {key:?}", - )) - } - } - } - - // Parse cipher suites - let mut ciphers: Vec = Vec::new(); - for (key, protocol) in - self.values_or_default(("server.listener", id, "tls.ciphers"), "server.tls.ciphers") - { - ciphers.push(protocol.parse_key(key)?); - } - - // Obtain default certificate - let cert_id = self - .value_or_default( - ("server.listener", id, "tls.certificate"), - "server.tls.certificate", - ) - .ok_or_else(|| format!("Undefined certificate id for listener {id:?}."))?; - let cert = self.rustls_certificate(cert_id)?; - let pki = self.rustls_private_key(cert_id)?; - - // Add SNI certificates - let mut resolver = ResolvesServerCertUsingSni::new(); - let mut has_sni = false; - for (key, value) in - self.values_or_default(("server.listener", id, "tls.sni"), "server.tls.sni") - { - if let Some(prefix) = key.strip_suffix(".subject") { - has_sni = true; - resolver - .add( - value, - match self.value((prefix, "certificate")) { - Some(sni_cert_id) if sni_cert_id != cert_id => CertifiedKey { - cert: self.rustls_certificate(sni_cert_id)?, - key: any_supported_type(&self.rustls_private_key(sni_cert_id)?) - .map_err(|err| { - format!( - "Failed to sign SNI certificate for {key:?}: {err}", - ) - })?, - ocsp: None, - }, - _ => CertifiedKey { - cert: cert.clone(), - key: - any_supported_type(&pki).map_err(|err| { - format!( - "Failed to sign SNI certificate for {key:?}: {err}", - ) - })?, - ocsp: None, - }, - }, - ) - .map_err(|err| { - format!("Failed to add SNI certificate for {key:?}: {err}") - })?; - } - } - - // Add default certificate - let default_cert = Some(Arc::new(CertifiedKey { - cert, - key: any_supported_type(&pki) - .map_err(|err| format!("Failed to sign certificate id {cert_id:?}: {err}"))?, - ocsp: None, - })); - - // Build cert provider - let mut provider = default_provider(); - if !ciphers.is_empty() { - provider.cipher_suites = ciphers; - } - - // Build server config - let mut config = ServerConfig::builder_with_provider(provider.into()) - .with_protocol_versions(if tls_v3 == tls_v2 { - ALL_VERSIONS - } else if tls_v3 { - TLS13_VERSION - } else { - TLS12_VERSION - }) - .map_err(|err| format!("Failed to build TLS config: {err}"))? - .with_no_client_auth() - .with_cert_resolver(Arc::new(CertificateResolver { - resolver: if has_sni { resolver.into() } else { None }, - default_cert, - })); - - //config.key_log = Arc::new(KeyLogger::default()); - config.ignore_client_order = self - .property_or_default( - ("server.listener", id, "tls.ignore-client-order"), - "server.tls.ignore-client-order", - )? - .unwrap_or(true); - ( - config.into(), - self.property_or_default( - ("server.listener", id, "tls.implicit"), - "server.tls.implicit", - )? - .unwrap_or(true), - ) - } else { - (None, false) - }; - + fn parse_server( + &self, + id: &str, + certificates: &AHashMap>, + acmes: &AHashMap>, + ) -> super::Result { // Build listeners let mut listeners = Vec::new(); for result in self.properties::(("server.listener", id, "bind")) { @@ -267,6 +182,150 @@ impl Config { return Err(format!("No 'bind' directive found for listener id {id:?}")); } + // Build TLS config + let (acceptor, tls_implicit) = if self + .property_or_default(("server.listener", id, "tls.enable"), "server.tls.enable")? + .unwrap_or(false) + { + // Parse protocol versions + let mut tls_v2 = false; + let mut tls_v3 = false; + for (key, protocol) in self.values_or_default( + ("server.listener", id, "tls.protocols"), + "server.tls.protocols", + ) { + match protocol { + "TLSv1.2" | "0x0303" => tls_v2 = true, + "TLSv1.3" | "0x0304" => tls_v3 = true, + protocol => { + return Err(format!( + "Unsupported TLS protocol {protocol:?} found in key {key:?}", + )) + } + } + } + + // Parse cipher suites + let mut ciphers: Vec = Vec::new(); + for (key, protocol) in + self.values_or_default(("server.listener", id, "tls.ciphers"), "server.tls.ciphers") + { + ciphers.push(protocol.parse_key(key)?); + } + + // Build resolver + let mut acme_acceptor = None; + let resolver: Arc = if let Some(acme_id) = + self.value_or_default(("server.listener", id, "tls.acme"), "server.tls.acme") + { + let acme = acmes.get(acme_id).ok_or_else(|| { + format!("Undefined ACME id {acme_id:?} for listener {id:?}.",) + })?; + + // Check if this port is used to receive ACME challenges + let acme_port = self.property_or_static::(("acme", acme_id, "port"), "443")?; + if listeners.iter().any(|l| l.addr.port() == acme_port) { + acme_acceptor = Some(acme.clone()); + } + + acme.clone() + } else { + let cert_id = self + .value_or_default( + ("server.listener", id, "tls.certificate"), + "server.tls.certificate", + ) + .ok_or_else(|| format!("Undefined certificate id for listener {id:?}."))?; + let mut resolver = CertificateResolver { + sni: Default::default(), + cert: certificates + .get(cert_id) + .ok_or_else(|| { + format!("Undefined certificate id {cert_id:?} for listener {id:?}.",) + })? + .clone(), + }; + + // Add SNI certificates + for (key, value) in + self.values_or_default(("server.listener", id, "tls.sni"), "server.tls.sni") + { + if let Some(prefix) = key.strip_suffix(".subject") { + resolver + .add( + value, + match self.value((prefix, "certificate")) { + Some(sni_cert_id) if sni_cert_id != cert_id => { + certificates.get(sni_cert_id).ok_or_else(|| { + format!( + "Undefined certificate id {sni_cert_id:?} for SNI {value:?} in listener {id:?}.", + ) + })?.clone() + } + _ => resolver.cert.clone(), + }, + ) + .map_err(|err| { + format!("Failed to add SNI certificate for {key:?}: {err}") + })?; + } + } + + Arc::new(resolver) + }; + + // Build cert provider + let mut provider = default_provider(); + if !ciphers.is_empty() { + provider.cipher_suites = ciphers; + } + + // Build server config + let mut config = ServerConfig::builder_with_provider(provider.into()) + .with_protocol_versions(if tls_v3 == tls_v2 { + ALL_VERSIONS + } else if tls_v3 { + TLS13_VERSION + } else { + TLS12_VERSION + }) + .map_err(|err| format!("Failed to build TLS config: {err}"))? + .with_no_client_auth() + .with_cert_resolver(resolver.clone()); + config.ignore_client_order = self + .property_or_default( + ("server.listener", id, "tls.ignore-client-order"), + "server.tls.ignore-client-order", + )? + .unwrap_or(true); + + // Build acceptor + let acceptor = if let Some(manager) = acme_acceptor { + let mut challenge = ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(resolver); + challenge.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec()); + TcpAcceptor::Acme { + challenge: Arc::new(challenge), + default: Arc::new(config), + manager, + } + } else { + TcpAcceptor::Tls(TlsAcceptor::from(Arc::new(config))) + }; + + ( + acceptor, + self.property_or_default( + ("server.listener", id, "tls.implicit"), + "server.tls.implicit", + )? + .unwrap_or(true), + ) + } else { + (TcpAcceptor::Plain, false) + }; + let protocol = self.property_require(("server.listener", id, "protocol"))?; Ok(Server { @@ -303,7 +362,7 @@ impl Config { .unwrap_or(8192), protocol, listeners, - tls, + acceptor, tls_implicit, }) } diff --git a/crates/utils/src/config/mod.rs b/crates/utils/src/config/mod.rs index 6b29b35d..0edf72b9 100644 --- a/crates/utils/src/config/mod.rs +++ b/crates/utils/src/config/mod.rs @@ -21,11 +21,11 @@ * for more details. */ -pub mod certificate; pub mod cron; pub mod dynvalue; pub mod listener; pub mod parser; +pub mod tls; pub mod utils; use std::{ @@ -33,14 +33,19 @@ use std::{ collections::BTreeMap, fmt::Display, net::{IpAddr, Ipv4Addr, SocketAddr}, + sync::Arc, time::Duration, }; use ahash::{AHashMap, AHashSet}; -use rustls::ServerConfig; use tokio::net::TcpSocket; -use crate::{failed, UnwrapFailure}; +use crate::{ + acme::AcmeManager, + failed, + listener::{tls::Certificate, TcpAcceptor}, + UnwrapFailure, +}; use self::utils::ParseValue; @@ -57,13 +62,16 @@ pub struct Server { pub data: String, pub protocol: ServerProtocol, pub listeners: Vec, - pub tls: Option, + pub acceptor: TcpAcceptor, pub tls_implicit: bool, pub max_connections: u64, } +#[derive(Default)] pub struct Servers { pub inner: Vec, + pub certificates: Vec>, + pub acme_managers: Vec>, } #[derive(Debug)] diff --git a/crates/utils/src/config/tls.rs b/crates/utils/src/config/tls.rs new file mode 100644 index 00000000..076d251c --- /dev/null +++ b/crates/utils/src/config/tls.rs @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::{io::Cursor, path::PathBuf, sync::Arc, time::Duration}; + +use ahash::AHashMap; +use arc_swap::ArcSwap; +use rcgen::generate_simple_self_signed; +use rustls::{ + crypto::ring::sign::any_supported_type, + sign::CertifiedKey, + version::{TLS12, TLS13}, + SupportedProtocolVersion, +}; +use rustls_pemfile::{certs, read_one, Item}; +use rustls_pki_types::PrivateKeyDer; + +use crate::{ + acme::{directory::LETS_ENCRYPT_PRODUCTION_DIRECTORY, AcmeManager}, + listener::tls::Certificate, +}; + +use super::Config; + +pub static TLS13_VERSION: &[&SupportedProtocolVersion] = &[&TLS13]; +pub static TLS12_VERSION: &[&SupportedProtocolVersion] = &[&TLS12]; + +impl Config { + pub fn parse_certificates(&self) -> super::Result>> { + let mut certs = AHashMap::new(); + for cert_id in self.sub_keys("certificate") { + let key_cert = ("certificate", cert_id, "cert"); + let key_pk = ("certificate", cert_id, "private-key"); + + let mut cert = Certificate { + cert: ArcSwap::from(Arc::new(build_certified_key( + self.file_contents(key_cert)?, + self.file_contents(key_pk)?, + &format!("certificate.{cert_id}"), + )?)), + path: Vec::with_capacity(2), + }; + + for key in [key_cert, key_pk] { + if let Some(path) = self.value(key).and_then(|v| v.strip_prefix("file://")) { + cert.path.push(PathBuf::from(path)); + } + } + + certs.insert(cert_id.to_string(), Arc::new(cert)); + } + + Ok(certs) + } + + pub fn parse_acmes(&self) -> super::Result>> { + let mut acmes = AHashMap::new(); + for acme_id in self.sub_keys("acme") { + let directory = self + .value(("acme", acme_id, "directory")) + .unwrap_or(LETS_ENCRYPT_PRODUCTION_DIRECTORY) + .trim() + .to_string(); + let contact = self + .values(("acme", acme_id, "contact")) + .filter_map(|(_, v)| { + let v = v.trim().to_string(); + if !v.is_empty() { + Some(v) + } else { + None + } + }) + .collect::>(); + let cache = PathBuf::from(self.value_require(("acme", acme_id, "cache"))?); + if !cache.exists() { + std::fs::create_dir_all(&cache).map_err(|err| { + format!("Failed to create ACME cache directory {:?}: {}", cache, err) + })?; + } + + let renew_before: Duration = + self.property_or_static(("acme", acme_id, "renew-before"), "30d")?; + + if directory.is_empty() { + return Err(format!("Missing directory for acme.{acme_id}.")); + } + + if contact.is_empty() { + return Err(format!("Missing contact for acme.{acme_id}.")); + } + + // Find which domains are covered by this ACME manager + let mut domains = Vec::new(); + for id in self.sub_keys("server.listener") { + match ( + self.value_or_default(("server.listener", id, "tls.acme"), "server.tls.acme"), + self.value_or_default(("server.listener", id, "hostname"), "server.hostname"), + ) { + (Some(listener_acme), Some(hostname)) if listener_acme == acme_id => { + let hostname = hostname.trim().to_lowercase(); + + if !domains.contains(&hostname) { + domains.push(hostname); + } + } + _ => (), + } + } + + acmes.insert( + acme_id.to_string(), + Arc::new(AcmeManager::new( + directory, + domains, + contact, + renew_before, + cache, + )?), + ); + } + + Ok(acmes) + } +} + +pub(crate) fn build_certified_key( + cert: Vec, + pk: Vec, + id: &str, +) -> super::Result { + let cert = certs(&mut Cursor::new(cert)) + .collect::, _>>() + .map_err(|err| format!("Failed to read certificates in {id:?}: {err}"))?; + if cert.is_empty() { + return Err(format!("No certificates found in {id:?}.")); + } + let pk = match read_one(&mut Cursor::new(pk)) + .map_err(|err| format!("Failed to read private keys in {id:?}.: {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(format!("Unsupported private keys found in {id:?}.",)), + None => return Err(format!("No private keys found in {id:?}.",)), + }; + + Ok(CertifiedKey { + cert, + key: any_supported_type(&pk) + .map_err(|err| format!("Failed to sign certificate for {id:?}: {err}",))?, + ocsp: None, + }) +} + +pub(crate) fn build_self_signed_cert(domains: &[String]) -> super::Result { + let cert = generate_simple_self_signed(domains).map_err(|err| { + format!( + "Failed to generate self-signed certificate for {domains:?}: {err}", + domains = domains + ) + })?; + build_certified_key( + cert.serialize_pem().unwrap().into_bytes(), + cert.serialize_private_key_pem().into_bytes(), + "self-signed", + ) +} diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index b9272be5..10117e5c 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -25,6 +25,7 @@ use std::{collections::HashMap, sync::Arc}; use config::Config; +pub mod acme; pub mod codec; pub mod config; pub mod ipc; diff --git a/crates/utils/src/listener/listen.rs b/crates/utils/src/listener/listen.rs index 53f86b51..3812176a 100644 --- a/crates/utils/src/listener/listen.rs +++ b/crates/utils/src/listener/listen.rs @@ -28,17 +28,18 @@ use tokio::{ net::{TcpListener, TcpStream}, sync::watch, }; -use tokio_rustls::{server::TlsStream, TlsAcceptor}; +use tokio_rustls::server::TlsStream; use tracing::Span; use crate::{ + acme::SpawnAcme, config::{Config, Listener, Server, ServerProtocol, Servers}, failed, listener::SessionData, UnwrapFailure, }; -use super::{limiter::ConcurrencyLimiter, ServerInstance, SessionManager}; +use super::{limiter::ConcurrencyLimiter, ServerInstance, SessionManager, TcpAcceptorResult}; impl Server { pub fn spawn(self, manager: impl SessionManager, shutdown_rx: watch::Receiver) { @@ -53,7 +54,7 @@ impl Server { listener_id: self.internal_id, protocol: self.protocol, hostname: self.hostname, - tls_acceptor: self.tls.map(|config| TlsAcceptor::from(Arc::new(config))), + acceptor: self.acceptor, is_tls_implicit: self.tls_implicit, limiter: ConcurrencyLimiter::new(self.max_connections), shutdown_rx, @@ -223,6 +224,11 @@ impl Servers { spawn(server, shutdown_rx.clone()); } + // Spawn ACME managers + for acme_manager in self.acme_managers { + acme_manager.spawn(shutdown_rx.clone()); + } + (shutdown_tx, shutdown_rx) } } @@ -241,24 +247,36 @@ impl ServerInstance { stream: TcpStream, span: &Span, ) -> Result, ()> { - match self.tls_acceptor.as_ref().unwrap().accept(stream).await { - Ok(stream) => { - tracing::info!( - parent: span, - context = "tls", - event = "handshake", - version = ?stream.get_ref().1.protocol_version().unwrap_or(rustls::ProtocolVersion::TLSv1_3), - cipher = ?stream.get_ref().1.negotiated_cipher_suite().unwrap_or(TLS13_AES_128_GCM_SHA256), - ); - Ok(stream) - } - Err(err) => { + match self.acceptor.accept(stream).await { + TcpAcceptorResult::Tls(accept) => match accept.await { + Ok(stream) => { + tracing::info!( + parent: span, + context = "tls", + event = "handshake", + version = ?stream.get_ref().1.protocol_version().unwrap_or(rustls::ProtocolVersion::TLSv1_3), + cipher = ?stream.get_ref().1.negotiated_cipher_suite().unwrap_or(TLS13_AES_128_GCM_SHA256), + ); + Ok(stream) + } + Err(err) => { + tracing::debug!( + parent: span, + context = "tls", + event = "error", + "Failed to accept TLS connection: {}", + err + ); + Err(()) + } + }, + TcpAcceptorResult::Plain(_) | TcpAcceptorResult::Close => { tracing::debug!( parent: span, context = "tls", event = "error", "Failed to accept TLS connection: {}", - err + "TLS is not configured for this server." ); Err(()) } diff --git a/crates/utils/src/listener/mod.rs b/crates/utils/src/listener/mod.rs index b65dbde0..f2c2d900 100644 --- a/crates/utils/src/listener/mod.rs +++ b/crates/utils/src/listener/mod.rs @@ -23,19 +23,21 @@ use std::{net::IpAddr, sync::Arc}; +use crate::{acme::AcmeManager, config::ServerProtocol}; +use rustls::ServerConfig; +use std::fmt::Debug; use tokio::{ io::{AsyncRead, AsyncWrite}, net::TcpStream, sync::watch, }; -use tokio_rustls::TlsAcceptor; - -use crate::config::ServerProtocol; +use tokio_rustls::{Accept, TlsAcceptor}; use self::limiter::{ConcurrencyLimiter, InFlight}; pub mod limiter; pub mod listen; +pub mod tls; pub struct ServerInstance { pub id: String, @@ -43,12 +45,34 @@ pub struct ServerInstance { pub protocol: ServerProtocol, pub hostname: String, pub data: String, - pub tls_acceptor: Option, + pub acceptor: TcpAcceptor, pub is_tls_implicit: bool, pub limiter: ConcurrencyLimiter, pub shutdown_rx: watch::Receiver, } +#[derive(Default)] +pub enum TcpAcceptor { + Tls(TlsAcceptor), + Acme { + challenge: Arc, + default: Arc, + manager: Arc, + }, + #[default] + Plain, +} + +#[allow(clippy::large_enum_variant)] +pub enum TcpAcceptorResult +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + Tls(Accept), + Plain(IO), + Close, +} + pub struct SessionData { pub stream: T, pub local_ip: IpAddr, @@ -63,3 +87,22 @@ pub trait SessionManager: Sync + Send + 'static + Clone { fn spawn(&self, session: SessionData); fn shutdown(&self); } + +impl Debug for TcpAcceptor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Tls(_) => f.debug_tuple("Tls").finish(), + Self::Acme { + challenge, + default, + manager, + } => f + .debug_struct("Acme") + .field("challenge", challenge) + .field("default", default) + .field("manager", manager) + .finish(), + Self::Plain => write!(f, "Plain"), + } + } +} diff --git a/crates/utils/src/listener/tls.rs b/crates/utils/src/listener/tls.rs new file mode 100644 index 00000000..1e12f188 --- /dev/null +++ b/crates/utils/src/listener/tls.rs @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::{ + fmt::{self, Formatter}, + path::PathBuf, + sync::Arc, +}; + +use ahash::AHashMap; +use arc_swap::ArcSwap; +use rustls::{ + client::verify_server_name, + server::{ClientHello, ParsedCertificate, ResolvesServerCert}, + sign::CertifiedKey, + version::{TLS12, TLS13}, + Error, SupportedProtocolVersion, +}; +use rustls_pki_types::{DnsName, ServerName}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; +use tokio_rustls::{Accept, LazyConfigAcceptor, TlsAcceptor}; + +use crate::{acme::resolver::IsTlsAlpnChallenge, config::tls::build_certified_key}; + +use super::{TcpAcceptor, TcpAcceptorResult}; + +pub static TLS13_VERSION: &[&SupportedProtocolVersion] = &[&TLS13]; +pub static TLS12_VERSION: &[&SupportedProtocolVersion] = &[&TLS12]; + +pub struct CertificateResolver { + pub sni: AHashMap>, + pub cert: Arc, +} + +pub struct Certificate { + pub cert: ArcSwap, + pub path: Vec, +} + +impl CertificateResolver { + pub fn add(&mut self, name: &str, ck: Arc) -> Result<(), Error> { + let server_name = { + let checked_name = DnsName::try_from(name) + .map_err(|_| Error::General("Bad DNS name".into())) + .map(|name| name.to_lowercase_owned())?; + ServerName::DnsName(checked_name) + }; + + ck.cert + .load() + .end_entity_cert() + .and_then(ParsedCertificate::try_from) + .and_then(|cert| verify_server_name(&cert, &server_name))?; + + if let ServerName::DnsName(name) = server_name { + self.sni.insert(name.as_ref().to_string(), ck); + } + Ok(()) + } +} + +impl ResolvesServerCert for CertificateResolver { + fn resolve(&self, hello: ClientHello<'_>) -> Option> { + if !self.sni.is_empty() { + if let Some(cert) = hello.server_name().and_then(|name| self.sni.get(name)) { + return cert.cert.load().clone().into(); + } + } + self.cert.cert.load().clone().into() + } +} + +impl TcpAcceptor { + pub async fn accept(&self, stream: IO) -> TcpAcceptorResult + where + IO: AsyncRead + AsyncWrite + Unpin, + { + match self { + TcpAcceptor::Tls(acceptor) => TcpAcceptorResult::Tls(acceptor.accept(stream)), + TcpAcceptor::Acme { + challenge, + default, + manager, + } => { + if manager.has_order_in_progress() { + match LazyConfigAcceptor::new(Default::default(), stream).await { + Ok(start_handshake) => { + if start_handshake.client_hello().is_tls_alpn_challenge() { + match start_handshake.into_stream(challenge.clone()).await { + Ok(mut tls) => { + tracing::debug!( + context = "acme", + event = "validation", + "Received TLS-ALPN-01 validation request." + ); + let _ = tls.shutdown().await; + } + Err(err) => { + tracing::info!( + context = "acme", + event = "error", + error = ?err, + "TLS-ALPN-01 validation request failed." + ); + } + } + } else { + return TcpAcceptorResult::Tls( + start_handshake.into_stream(default.clone()), + ); + } + } + Err(err) => { + tracing::debug!( + context = "listener", + event = "error", + error = ?err, + "TLS handshake failed." + ); + } + } + + TcpAcceptorResult::Close + } else { + TcpAcceptorResult::Tls(TlsAcceptor::from(default.clone()).accept(stream)) + } + } + TcpAcceptor::Plain => TcpAcceptorResult::Plain(stream), + } + } + + pub fn is_tls(&self) -> bool { + matches!(self, TcpAcceptor::Tls(_) | TcpAcceptor::Acme { .. }) + } +} + +impl TcpAcceptorResult +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + pub fn unwrap_tls(self) -> Accept { + match self { + TcpAcceptorResult::Tls(accept) => accept, + _ => panic!("unwrap_tls called on non-TLS acceptor"), + } + } +} + +impl Certificate { + pub async fn reload(&self) -> crate::config::Result<()> { + let cert = build_certified_key( + tokio::fs::read(&self.path[0]).await.map_err(|err| { + format!( + "Failed to read certificate from path {id:?}: {err}", + id = self.path[0] + ) + })?, + tokio::fs::read(&self.path[1]).await.map_err(|err| { + format!( + "Failed to read private key from path {id:?}: {err}", + id = self.path[1] + ) + })?, + "certificate", + )?; + + self.cert.store(Arc::new(cert)); + + Ok(()) + } +} + +impl std::fmt::Debug for CertificateResolver { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("CertificateResolver") + .field("sni", &self.sni.keys()) + .field("cert", &self.cert.path) + .finish() + } +} diff --git a/resources/config.zip b/resources/config.zip index 8ea3b059e033fc31ccceed73e45d4c3faa2d2d0e..1e4047ad1a9d20b4e6858d42581e991785ac0cd1 100644 GIT binary patch delta 4523 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 delta 4508 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 IMAPTest { .replace("{TMP}", &temp_dir.path.display().to_string()), ) .unwrap(); - let servers = config.parse_servers().unwrap(); + let mut servers = config.parse_servers().unwrap(); let stores = config.parse_stores().await.failed("Invalid configuration"); let directory = config .parse_directory(&stores, store_id.into()) @@ -289,9 +289,16 @@ async fn init_imap_tests(store_id: &str, delete_if_exists: bool) -> IMAPTest { let smtp = SMTP::init(&config, &servers, &stores, &directory, delivery_tx) .await .failed("Invalid configuration file"); - let jmap = JMAP::init(&config, &stores, &directory, delivery_rx, smtp.clone()) - .await - .failed("Invalid configuration file"); + let jmap = JMAP::init( + &config, + &stores, + &directory, + std::mem::take(&mut servers.certificates), + delivery_rx, + smtp.clone(), + ) + .await + .failed("Invalid configuration file"); let imap: Arc = IMAP::init(&config) .await .failed("Invalid configuration file"); diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index 8abb8929..0f068ba3 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -390,7 +390,7 @@ async fn init_jmap_tests(store_id: &str, delete_if_exists: bool) -> JMAPTest { .replace("{TMP}", &temp_dir.path.display().to_string()), ) .unwrap(); - let servers = config.parse_servers().unwrap(); + let mut servers = config.parse_servers().unwrap(); let stores = config.parse_stores().await.failed("Invalid configuration"); let directory = config .parse_directory(&stores, store_id.into()) @@ -403,9 +403,16 @@ async fn init_jmap_tests(store_id: &str, delete_if_exists: bool) -> JMAPTest { let smtp = SMTP::init(&config, &servers, &stores, &directory, delivery_tx) .await .failed("Invalid configuration file"); - let jmap = JMAP::init(&config, &stores, &directory, delivery_rx, smtp.clone()) - .await - .failed("Invalid configuration file"); + let jmap = JMAP::init( + &config, + &stores, + &directory, + std::mem::take(&mut servers.certificates), + delivery_rx, + smtp.clone(), + ) + .await + .failed("Invalid configuration file"); let (shutdown_tx, _) = servers.spawn(|server, shutdown_rx| { match &server.protocol { ServerProtocol::Smtp | ServerProtocol::Lmtp => { diff --git a/tests/src/jmap/push_subscription.rs b/tests/src/jmap/push_subscription.rs index 2c0c1529..3d2bff25 100644 --- a/tests/src/jmap/push_subscription.rs +++ b/tests/src/jmap/push_subscription.rs @@ -296,11 +296,11 @@ impl utils::listener::SessionManager for SessionManager { TokioIo::new( session .instance - .tls_acceptor - .as_ref() - .unwrap() + .acceptor .accept(session.stream) .await + .unwrap_tls() + .await .unwrap(), ), service_fn(|mut req: hyper::Request| { diff --git a/tests/src/smtp/config.rs b/tests/src/smtp/config.rs index 0fd2f1d3..c2bdff41 100644 --- a/tests/src/smtp/config.rs +++ b/tests/src/smtp/config.rs @@ -37,7 +37,10 @@ use store::{ }; use tokio::net::TcpSocket; -use utils::config::{Config, DynValue, KeyLookup, Listener, Rate, Server, ServerProtocol}; +use utils::{ + config::{Config, DynValue, KeyLookup, Listener, Rate, Server, ServerProtocol}, + listener::TcpAcceptor, +}; use ahash::AHashMap; @@ -449,7 +452,7 @@ fn parse_servers() { linger: None, nodelay: true, }], - tls: None, + acceptor: TcpAcceptor::Plain, tls_implicit: false, max_connections: 8192, }, @@ -477,7 +480,7 @@ fn parse_servers() { nodelay: true, }, ], - tls: None, + acceptor: TcpAcceptor::Plain, tls_implicit: true, max_connections: 1024, }, @@ -495,7 +498,7 @@ fn parse_servers() { linger: None, nodelay: true, }], - tls: None, + acceptor: TcpAcceptor::Plain, tls_implicit: true, max_connections: 8192, }, diff --git a/tests/src/smtp/session.rs b/tests/src/smtp/session.rs index 0570809a..7fd61023 100644 --- a/tests/src/smtp/session.rs +++ b/tests/src/smtp/session.rs @@ -34,7 +34,7 @@ use smtp::{ }; use utils::{ config::ServerProtocol, - listener::{limiter::ConcurrencyLimiter, ServerInstance}, + listener::{limiter::ConcurrencyLimiter, ServerInstance, TcpAcceptor}, }; use super::TestConfig; @@ -368,7 +368,7 @@ impl TestServerInstance for ServerInstance { hostname: "mx.example.org".to_string(), protocol: ServerProtocol::Smtp, data: "220 mx.example.org at your service.\r\n".to_string(), - tls_acceptor: None, + acceptor: TcpAcceptor::Plain, is_tls_implicit: false, limiter: ConcurrencyLimiter::new(100), shutdown_rx,