From 5b294b7f880e9191eb689ec14d9c92ae62769c15 Mon Sep 17 00:00:00 2001 From: Chase Douglas Date: Thu, 29 May 2025 16:19:26 -0700 Subject: [PATCH] Add support for sending email via AWS SES --- Cargo.lock | 277 ++++++++++++++++++++++++++++++++++++++++++++++---- Cargo.toml | 5 + build.rs | 3 + src/config.rs | 38 ++++--- src/mail.rs | 49 +++++++++ 5 files changed, 340 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a16dbbd..b26d4485 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,6 +418,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "aws-sdk-sesv2" +version = "1.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31bd9848a6199a2600ddc6cf2298c93b428c903887c6fcfbd89a48e40822ca1" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-sso" version = "1.68.0" @@ -496,15 +518,20 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "bytes", + "crypto-bigint 0.5.5", "form_urlencoded", "hex", "hmac", "http 0.2.12", "http 1.3.1", + "p256", "percent-encoding", + "ring", "sha2", + "subtle", "time", "tracing", + "zeroize", ] [[package]] @@ -547,14 +574,19 @@ dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2", + "h2 0.4.10", + "http 0.2.12", "http 1.3.1", + "http-body 0.4.6", + "hyper 0.14.32", "hyper 1.6.0", - "hyper-rustls", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.6", "hyper-util", "pin-project-lite", + "rustls 0.21.12", "rustls 0.23.27", - "rustls-native-certs", + "rustls-native-certs 0.8.1", "rustls-pki-types", "tokio", "tower", @@ -639,6 +671,7 @@ dependencies = [ "base64-simd", "bytes", "bytes-utils", + "futures-core", "http 0.2.12", "http 1.3.1", "http-body 0.4.6", @@ -651,6 +684,8 @@ dependencies = [ "ryu", "serde", "time", + "tokio", + "tokio-util", ] [[package]] @@ -702,6 +737,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + [[package]] name = "base64" version = "0.13.1" @@ -1202,6 +1243,28 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -1273,6 +1336,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "der" version = "0.7.9" @@ -1523,12 +1596,44 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der 0.6.1", + "elliptic-curve", + "rfc6979", + "signature 1.6.4", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der 0.6.1", + "digest", + "ff", + "generic-array", + "group", + "pkcs8 0.9.0", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email-encoding" version = "0.4.1" @@ -1636,6 +1741,16 @@ dependencies = [ "syslog", ] +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "figment" version = "0.10.19" @@ -1934,6 +2049,36 @@ dependencies = [ "phf", ] +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.10" @@ -2183,6 +2328,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", + "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "httparse", @@ -2205,7 +2351,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "h2", + "h2 0.4.10", "http 1.3.1", "http-body 1.0.1", "httparse", @@ -2216,6 +2362,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "rustls-native-certs 0.6.3", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.6" @@ -2226,7 +2388,7 @@ dependencies = [ "hyper 1.6.0", "hyper-util", "rustls 0.23.27", - "rustls-native-certs", + "rustls-native-certs 0.8.1", "rustls-pki-types", "tokio", "tokio-rustls 0.26.2", @@ -3136,6 +3298,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -3387,9 +3560,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.9", + "pkcs8 0.10.2", + "spki 0.7.3", ] [[package]] @@ -3400,11 +3573,21 @@ checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" dependencies = [ "aes", "cbc", - "der", + "der 0.7.9", "pbkdf2", "scrypt", "sha2", - "spki", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der 0.6.1", + "spki 0.6.0", ] [[package]] @@ -3413,10 +3596,10 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", + "der 0.7.9", "pkcs5", "rand_core 0.6.4", - "spki", + "spki 0.7.3", ] [[package]] @@ -3853,12 +4036,12 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.4.10", "http 1.3.1", "http-body 1.0.1", "http-body-util", "hyper 1.6.0", - "hyper-rustls", + "hyper-rustls 0.27.6", "hyper-tls", "hyper-util", "ipnet", @@ -3900,6 +4083,17 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95325155c684b1c89f7765e30bc1c42e4a6da51ca513615660cb8a62ef9a88e3" +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + [[package]] name = "ring" version = "0.17.14" @@ -4053,11 +4247,11 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", "sha2", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] @@ -4163,6 +4357,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile 1.0.4", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.1" @@ -4306,6 +4512,20 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der 0.6.1", + "generic-array", + "pkcs8 0.9.0", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -4467,6 +4687,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "2.2.0" @@ -4535,6 +4765,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der 0.6.1", +] + [[package]] name = "spki" version = "0.7.3" @@ -4542,7 +4782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.9", ] [[package]] @@ -5195,6 +5435,7 @@ dependencies = [ "argon2", "aws-config", "aws-credential-types", + "aws-sdk-sesv2", "bigdecimal", "bytes", "cached", diff --git a/Cargo.toml b/Cargo.toml index a871cc73..a66a5d72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,9 @@ enable_mimalloc = ["dep:mimalloc"] # You also need to set an env variable `QUERY_LOGGER=1` to fully activate this so you do not have to re-compile # if you want to turn off the logging for a specific run. query_logger = ["dep:diesel_logger"] +aws = ["s3", "ses"] s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:anyhow", "dep:reqsign"] +ses = ["dep:aws-config", "dep:aws-sdk-sesv2"] # Enable unstable features, requires nightly # Currently only used to enable rusts official ip support @@ -187,6 +189,9 @@ aws-config = { version = "1.6.3", features = ["behavior-version-latest"], option aws-credential-types = { version = "1.2.3", optional = true } reqsign = { version = "0.16.3", optional = true } +# AWS Simple Email Service (SES) for sending emails +aws-sdk-sesv2 = { version = "1.81.0", features = ["behavior-version-latest"], optional = true } + # Strip debuginfo from the release builds # The debug symbols are to provide better panic traces # Also enable fat LTO and use 1 codegen unit for optimizations diff --git a/build.rs b/build.rs index 1dbb1a0b..8285e2f1 100644 --- a/build.rs +++ b/build.rs @@ -13,6 +13,8 @@ fn main() { println!("cargo:rustc-cfg=query_logger"); #[cfg(feature = "s3")] println!("cargo:rustc-cfg=s3"); + #[cfg(feature = "ses")] + println!("cargo:rustc-cfg=ses"); #[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))] compile_error!( @@ -26,6 +28,7 @@ fn main() { println!("cargo::rustc-check-cfg=cfg(postgresql)"); println!("cargo::rustc-check-cfg=cfg(query_logger)"); println!("cargo::rustc-check-cfg=cfg(s3)"); + println!("cargo::rustc-check-cfg=cfg(ses)"); // Rerun when these paths are changed. // Someone could have checked-out a tag or specific commit, but no other files changed. diff --git a/src/config.rs b/src/config.rs index 5b995c6d..d197a2cd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -746,12 +746,14 @@ make_config! { smtp_accept_invalid_certs: bool, true, def, false; /// Accept Invalid Hostnames (Know the risks!) |> DANGEROUS: Allow invalid hostnames. This option introduces significant vulnerabilities to man-in-the-middle attacks! smtp_accept_invalid_hostnames: bool, true, def, false; + /// Use AWS SES |> Whether to send mail via AWS Simple Email Service (SES) + use_aws_ses: bool, true, def, false; }, /// Email 2FA Settings email_2fa: _enable_email_2fa { /// Enabled |> Disabling will prevent users from setting up new email 2FA and using existing email 2FA configured - _enable_email_2fa: bool, true, auto, |c| c._enable_smtp && (c.smtp_host.is_some() || c.use_sendmail); + _enable_email_2fa: bool, true, auto, |c| c._enable_smtp && (c.smtp_host.is_some() || c.use_sendmail || c.use_aws_ses); /// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting. email_token_size: u8, true, def, 6; /// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token. @@ -965,6 +967,9 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { } } } + } else if cfg.use_aws_ses { + #[cfg(not(ses))] + err!("`USE_AWS_SES` is set, but the `ses` feature is not enabled in this build"); } else { if cfg.smtp_host.is_some() == cfg.smtp_from.is_empty() { err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support without `USE_SENDMAIL`") @@ -975,7 +980,7 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { } } - if (cfg.smtp_host.is_some() || cfg.use_sendmail) && !is_valid_email(&cfg.smtp_from) { + if (cfg.smtp_host.is_some() || cfg.use_sendmail || cfg.use_aws_ses) && !is_valid_email(&cfg.smtp_from) { err!(format!("SMTP_FROM '{}' is not a valid email address", cfg.smtp_from)) } @@ -984,7 +989,7 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { } } - if cfg._enable_email_2fa && !(cfg.smtp_host.is_some() || cfg.use_sendmail) { + if cfg._enable_email_2fa && !(cfg.smtp_host.is_some() || cfg.use_sendmail || cfg.use_aws_ses) { err!("To enable email 2FA, a mail transport must be configured") } @@ -1186,6 +1191,15 @@ fn opendal_operator_for_path(path: &str) -> Result { Ok(operator) } +#[cfg(any(s3, ses))] +pub(crate) async fn aws_sdk_config() -> aws_config::SdkConfig { + use tokio::sync::OnceCell; + + static AWS_CONFIG: OnceCell = OnceCell::const_new(); + + AWS_CONFIG.get_or_init(aws_config::load_from_env).await.clone() +} + #[cfg(s3)] fn opendal_s3_operator_for_path(path: &str) -> Result { // This is a custom AWS credential loader that uses the official AWS Rust @@ -1198,17 +1212,13 @@ fn opendal_s3_operator_for_path(path: &str) -> Result impl reqsign::AwsCredentialLoad for OpenDALS3CredentialLoader { async fn load_credential(&self, _client: reqwest::Client) -> anyhow::Result> { use aws_credential_types::provider::ProvideCredentials as _; - use tokio::sync::OnceCell; - static DEFAULT_CREDENTIAL_CHAIN: OnceCell< - aws_config::default_provider::credentials::DefaultCredentialsChain, - > = OnceCell::const_new(); - - let chain = DEFAULT_CREDENTIAL_CHAIN - .get_or_init(|| aws_config::default_provider::credentials::DefaultCredentialsChain::builder().build()) - .await; - - let creds = chain.provide_credentials().await?; + let creds = aws_sdk_config() + .await + .credentials_provider() + .expect("AWS credentials provider should exist when loading credentials for OpenDAL S3 operator") + .provide_credentials() + .await?; Ok(Some(reqsign::AwsCredential { access_key_id: creds.access_key_id().to_string(), @@ -1386,7 +1396,7 @@ impl Config { } pub fn mail_enabled(&self) -> bool { let inner = &self.inner.read().unwrap().config; - inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail) + inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail || inner.use_aws_ses) } pub async fn get_duo_akey(&self) -> String { diff --git a/src/mail.rs b/src/mail.rs index b1f37886..a55d97c1 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -95,6 +95,46 @@ fn smtp_transport() -> AsyncSmtpTransport { smtp_client.build() } +#[cfg(ses)] +async fn send_with_aws_ses(email: Message) -> std::io::Result<()> { + use std::io::Error; + + use crate::config::aws_sdk_config; + use aws_sdk_sesv2::{ + types::{EmailContent, RawMessage}, + Client, + }; + use tokio::sync::OnceCell; + + static AWS_SESV2_CLIENT: OnceCell = OnceCell::const_new(); + + let client = AWS_SESV2_CLIENT + .get_or_init(|| async { + // Initialize the AWS SESv2 client lazily + let config = aws_sdk_config().await; + Client::new(&config) + }) + .await; + + client + .send_email() + .content( + EmailContent::builder() + .raw( + RawMessage::builder() + .data(email.formatted().into()) + .build() + .map_err(|e| Error::other(format!("Failed to build AWS SESv2 RawMessage: {e:#?}")))?, + ) + .build(), + ) + .send() + .await + .map_err(Error::other)?; + + Ok(()) +} + // This will sanitize the string values by stripping all the html tags to prevent XSS and HTML Injections fn sanitize_data(data: &mut serde_json::Value) { use regex::Regex; @@ -640,6 +680,15 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult { } } } + } else if CONFIG.use_aws_ses() { + #[cfg(ses)] + match send_with_aws_ses(email).await { + Ok(_) => Ok(()), + Err(e) => err!("Failed to send email", format!("Failed to send email using AWS SES: {e:?}")), + } + + #[cfg(not(ses))] + err!("Failed to send email", "Failed to send email using AWS SES: `ses` feature is not enabled"); } else { match smtp_transport().send(email).await { Ok(_) => Ok(()),