Author:
Hash:
Timestamp:
+285 -11 +/-7 browse
Kevin Schoon [me@kevinschoon.com]
7cdf752ffcd2d5f5ae04a9e507f9301d276ce34f
Sun, 01 Sep 2024 18:56:19 +0000 (1.2 years ago)
| 1 | diff --git a/Cargo.lock b/Cargo.lock |
| 2 | index 5bb94a1..a525bcc 100644 |
| 3 | --- a/Cargo.lock |
| 4 | +++ b/Cargo.lock |
| 5 | @@ -68,6 +68,12 @@ dependencies = [ |
| 6 | ] |
| 7 | |
| 8 | [[package]] |
| 9 | + name = "base64" |
| 10 | + version = "0.22.1" |
| 11 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
| 12 | + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" |
| 13 | + |
| 14 | + [[package]] |
| 15 | name = "bitflags" |
| 16 | version = "2.6.0" |
| 17 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 18 | @@ -307,6 +313,7 @@ name = "maitred" |
| 19 | version = "0.1.0" |
| 20 | dependencies = [ |
| 21 | "async-trait", |
| 22 | + "base64", |
| 23 | "bytes", |
| 24 | "crossbeam-deque", |
| 25 | "email_address", |
| 26 | @@ -314,6 +321,7 @@ dependencies = [ |
| 27 | "mail-parser", |
| 28 | "md5", |
| 29 | "smtp-proto", |
| 30 | + "stringprep", |
| 31 | "thiserror", |
| 32 | "tokio", |
| 33 | "tokio-stream", |
| 34 | @@ -551,6 +559,17 @@ dependencies = [ |
| 35 | ] |
| 36 | |
| 37 | [[package]] |
| 38 | + name = "stringprep" |
| 39 | + version = "0.1.5" |
| 40 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
| 41 | + checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" |
| 42 | + dependencies = [ |
| 43 | + "unicode-bidi", |
| 44 | + "unicode-normalization", |
| 45 | + "unicode-properties", |
| 46 | + ] |
| 47 | + |
| 48 | + [[package]] |
| 49 | name = "syn" |
| 50 | version = "2.0.72" |
| 51 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 52 | @@ -744,6 +763,12 @@ dependencies = [ |
| 53 | ] |
| 54 | |
| 55 | [[package]] |
| 56 | + name = "unicode-properties" |
| 57 | + version = "0.1.2" |
| 58 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
| 59 | + checksum = "52ea75f83c0137a9b98608359a5f1af8144876eb67bcb1ce837368e906a9f524" |
| 60 | + |
| 61 | + [[package]] |
| 62 | name = "url" |
| 63 | version = "2.5.2" |
| 64 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 65 | diff --git a/cmd/maitred-debug/src/main.rs b/cmd/maitred-debug/src/main.rs |
| 66 | index ec5c708..97457be 100644 |
| 67 | --- a/cmd/maitred-debug/src/main.rs |
| 68 | +++ b/cmd/maitred-debug/src/main.rs |
| 69 | @@ -1,7 +1,8 @@ |
| 70 | use tracing::Level; |
| 71 | |
| 72 | use maitred::{ |
| 73 | - mail_parser::Message, DeliveryError, DeliveryFunc, Error, MilterFunc, Server, SessionOptions, |
| 74 | + mail_parser::Message, DeliveryError, DeliveryFunc, Error, MilterFunc, PlainAuthFunc, Server, |
| 75 | + SessionOptions, |
| 76 | }; |
| 77 | |
| 78 | async fn print_message(message: Message<'static>) -> Result<(), DeliveryError> { |
| 79 | @@ -31,13 +32,21 @@ async fn main() -> Result<(), Error> { |
| 80 | .address("127.0.0.1:2525") |
| 81 | .with_milter(MilterFunc(|message: &Message<'static>| { |
| 82 | let message = message.clone(); |
| 83 | - Box::pin(async move { Ok(message.to_owned()) }) |
| 84 | + async move { Ok(message.to_owned()) } |
| 85 | })) |
| 86 | .with_delivery(DeliveryFunc(|message: &Message<'static>| { |
| 87 | let message = message.clone(); |
| 88 | - Box::pin(async move { print_message(message.to_owned()).await }) |
| 89 | + async move { print_message(message.to_owned()).await } |
| 90 | })) |
| 91 | - .with_session_opts(SessionOptions::default()); |
| 92 | + .with_session_opts(SessionOptions::default().plain_auth(PlainAuthFunc( |
| 93 | + |authcid: &str, authzid: &str, passwd: &str| { |
| 94 | + println!( |
| 95 | + "AUTHCID: {}, AUTHZID: {}, PASSWD: {}", |
| 96 | + authcid, authzid, passwd |
| 97 | + ); |
| 98 | + async move { Ok(()) } |
| 99 | + }, |
| 100 | + ))); |
| 101 | mail_server.listen().await?; |
| 102 | Ok(()) |
| 103 | } |
| 104 | diff --git a/maitred/Cargo.toml b/maitred/Cargo.toml |
| 105 | index 4ca527d..d91b51e 100644 |
| 106 | --- a/maitred/Cargo.toml |
| 107 | +++ b/maitred/Cargo.toml |
| 108 | @@ -5,6 +5,7 @@ edition = "2021" |
| 109 | |
| 110 | [dependencies] |
| 111 | async-trait = "0.1.81" |
| 112 | + base64 = "0.22.1" |
| 113 | bytes = "1.6.1" |
| 114 | crossbeam-deque = "0.8.5" |
| 115 | email_address = "0.2.9" |
| 116 | @@ -12,6 +13,7 @@ futures = "0.3.30" |
| 117 | mail-parser = { version = "0.9.3", features = ["serde", "serde_support"] } |
| 118 | md5 = "0.7.0" |
| 119 | smtp-proto = { version = "0.1.5", features = ["serde", "serde_support"] } |
| 120 | + stringprep = "0.1.5" |
| 121 | thiserror = "1.0.63" |
| 122 | tokio = { version = "1.39.2", features = ["full"] } |
| 123 | tokio-stream = { version = "0.1.15", features = ["full"] } |
| 124 | diff --git a/maitred/src/auth.rs b/maitred/src/auth.rs |
| 125 | new file mode 100644 |
| 126 | index 0000000..cea772e |
| 127 | --- /dev/null |
| 128 | +++ b/maitred/src/auth.rs |
| 129 | @@ -0,0 +1,181 @@ |
| 130 | + use std::{future::Future, string::FromUtf8Error}; |
| 131 | + |
| 132 | + use async_trait::async_trait; |
| 133 | + use base64::{prelude::*, DecodeError}; |
| 134 | + use stringprep::{saslprep, Error as SaslPrepError}; |
| 135 | + |
| 136 | + use crate::{smtp_response, Response}; |
| 137 | + use smtp_proto::Response as SmtpResponse; |
| 138 | + |
| 139 | + #[derive(Debug, thiserror::Error)] |
| 140 | + pub enum AuthError { |
| 141 | + #[error("Unauthorized")] |
| 142 | + Unauthorized, |
| 143 | + #[error("Input too long, maximum 255 characters")] |
| 144 | + InputTooLong, |
| 145 | + #[error("Not enough fields")] |
| 146 | + NotEnoughFields, |
| 147 | + #[error("Failed to decode authentication data: {0}")] |
| 148 | + Base64Decoding(#[from] DecodeError), |
| 149 | + #[error("Bad input: {0}")] |
| 150 | + SaslPrep(#[from] SaslPrepError), |
| 151 | + #[error("Not valid UTF8: {0}")] |
| 152 | + Utf8(#[from] FromUtf8Error), |
| 153 | + } |
| 154 | + |
| 155 | + #[allow(clippy::from_over_into)] |
| 156 | + impl Into<Response<String>> for AuthError { |
| 157 | + fn into(self) -> Response<String> { |
| 158 | + let message = self.to_string(); |
| 159 | + match self { |
| 160 | + AuthError::Unauthorized => { |
| 161 | + smtp_response!(400, 0, 0, 0, message) |
| 162 | + } |
| 163 | + AuthError::InputTooLong => { |
| 164 | + smtp_response!(500, 0, 0, 0, message) |
| 165 | + } |
| 166 | + AuthError::NotEnoughFields => { |
| 167 | + smtp_response!(500, 0, 0, 0, message) |
| 168 | + } |
| 169 | + AuthError::Base64Decoding(err) => { |
| 170 | + smtp_response!(500, 0, 0, 0, err.to_string()) |
| 171 | + } |
| 172 | + AuthError::SaslPrep(err) => { |
| 173 | + smtp_response!(500, 0, 0, 0, err.to_string()) |
| 174 | + } |
| 175 | + AuthError::Utf8(err) => { |
| 176 | + smtp_response!(500, 0, 0, 0, err.to_string()) |
| 177 | + } |
| 178 | + } |
| 179 | + } |
| 180 | + } |
| 181 | + |
| 182 | + #[async_trait] |
| 183 | + pub trait PlainAuth: Sync + Send { |
| 184 | + /// authenticate is passed the plaintext authcid, authzid, and passwd |
| 185 | + /// for the user. The implementer should return AuthError::Unauthorized |
| 186 | + /// if the credentials are invalid. |
| 187 | + async fn authenticate( |
| 188 | + &self, |
| 189 | + authcid: &str, |
| 190 | + authzid: &str, |
| 191 | + passwd: &str, |
| 192 | + ) -> Result<(), AuthError>; |
| 193 | + } |
| 194 | + |
| 195 | + pub struct PlainAuthFunc<F, T>(pub F) |
| 196 | + where |
| 197 | + F: Fn(&str, &str, &str) -> T + Sync + Send, |
| 198 | + T: Future<Output = Result<(), AuthError>> + Send; |
| 199 | + |
| 200 | + #[async_trait] |
| 201 | + impl<F, T> PlainAuth for PlainAuthFunc<F, T> |
| 202 | + where |
| 203 | + F: Fn(&str, &str, &str) -> T + Sync + Send, |
| 204 | + T: Future<Output = Result<(), AuthError>> + Send, |
| 205 | + { |
| 206 | + async fn authenticate( |
| 207 | + &self, |
| 208 | + authcid: &str, |
| 209 | + authzid: &str, |
| 210 | + passwd: &str, |
| 211 | + ) -> Result<(), AuthError> { |
| 212 | + let f = (self.0)(authcid, authzid, passwd); |
| 213 | + f.await |
| 214 | + } |
| 215 | + } |
| 216 | + |
| 217 | + /// Read a PLAIN SASL mechanism per RFC4616 |
| 218 | + /// The mechanism consists of a single message, a string of [UTF-8] |
| 219 | + /// encoded [Unicode] characters, from the client to the server. The |
| 220 | + /// client presents the authorization identity (identity to act as), |
| 221 | + /// followed by a NUL (U+0000) character, followed by the authentication |
| 222 | + /// identity (identity whose password will be used), followed by a NUL |
| 223 | + /// (U+0000) character, followed by the clear-text password. |
| 224 | + #[derive(Default)] |
| 225 | + pub(crate) struct AuthData { |
| 226 | + values: [String; 3], |
| 227 | + } |
| 228 | + |
| 229 | + impl AuthData { |
| 230 | + pub fn authcid(&self) -> String { |
| 231 | + self.values[0].clone() |
| 232 | + } |
| 233 | + |
| 234 | + pub fn authzid(&self) -> String { |
| 235 | + self.values[1].clone() |
| 236 | + } |
| 237 | + |
| 238 | + pub fn passwd(&self) -> String { |
| 239 | + self.values[2].clone() |
| 240 | + } |
| 241 | + } |
| 242 | + |
| 243 | + impl TryFrom<&str> for AuthData { |
| 244 | + type Error = AuthError; |
| 245 | + |
| 246 | + fn try_from(value: &str) -> Result<Self, Self::Error> { |
| 247 | + let decoded = BASE64_STANDARD.decode(value)?; |
| 248 | + let mut n = 0; |
| 249 | + let mut raw_data: [Vec<u8>; 3] = [ |
| 250 | + Vec::with_capacity(255), |
| 251 | + Vec::with_capacity(255), |
| 252 | + Vec::with_capacity(255), |
| 253 | + ]; |
| 254 | + for (i, ch) in decoded.iter().enumerate() { |
| 255 | + if *ch == b'\0' { |
| 256 | + if i > 0 { |
| 257 | + n += 1; |
| 258 | + } |
| 259 | + continue; |
| 260 | + } |
| 261 | + if raw_data[n].len() + 1 > 255 { |
| 262 | + return Err(AuthError::InputTooLong); |
| 263 | + } |
| 264 | + raw_data[n].push(*ch); |
| 265 | + } |
| 266 | + println!("N: {}", n); |
| 267 | + if n == 0 { |
| 268 | + return Err(AuthError::NotEnoughFields); |
| 269 | + } |
| 270 | + if raw_data[2].is_empty() { |
| 271 | + // if only an athcid and passwd were specified shift the value |
| 272 | + // from authzid. |
| 273 | + raw_data[2] = raw_data[1].clone(); |
| 274 | + raw_data[1] = raw_data[0].clone(); |
| 275 | + } |
| 276 | + // RFC4013 |
| 277 | + let sasl_authcid = String::from_utf8(raw_data[0].to_vec())?; |
| 278 | + let sasl_authcid = saslprep(&sasl_authcid)?; |
| 279 | + let sasl_authzid = String::from_utf8(raw_data[1].to_vec())?; |
| 280 | + let sasl_authzid = saslprep(&sasl_authzid)?; |
| 281 | + let sasl_passwd = String::from_utf8(raw_data[2].to_vec())?; |
| 282 | + let sasl_passwd = saslprep(&sasl_passwd)?; |
| 283 | + Ok(AuthData { |
| 284 | + values: [ |
| 285 | + sasl_authcid.to_string(), |
| 286 | + sasl_authzid.to_string(), |
| 287 | + sasl_passwd.to_string(), |
| 288 | + ], |
| 289 | + }) |
| 290 | + } |
| 291 | + } |
| 292 | + |
| 293 | + #[cfg(test)] |
| 294 | + mod tests { |
| 295 | + |
| 296 | + use super::*; |
| 297 | + use base64::engine::general_purpose::STANDARD; |
| 298 | + |
| 299 | + #[test] |
| 300 | + pub fn test_auth_data() { |
| 301 | + let data = AuthData::try_from(STANDARD.encode(b"\0hello\0world").as_str()).unwrap(); |
| 302 | + assert!(data.authcid() == "hello"); |
| 303 | + assert!(data.authzid() == "hello"); |
| 304 | + assert!(data.passwd() == "world"); |
| 305 | + let data = AuthData::try_from(STANDARD.encode(b"\0fuu\0bar\0baz").as_str()).unwrap(); |
| 306 | + assert!(data.authcid() == "fuu"); |
| 307 | + assert!(data.authzid() == "bar"); |
| 308 | + assert!(data.passwd() == "baz"); |
| 309 | + } |
| 310 | + } |
| 311 | diff --git a/maitred/src/lib.rs b/maitred/src/lib.rs |
| 312 | index d95857e..189649b 100644 |
| 313 | --- a/maitred/src/lib.rs |
| 314 | +++ b/maitred/src/lib.rs |
| 315 | @@ -47,6 +47,7 @@ |
| 316 | //! } |
| 317 | //! ``` |
| 318 | |
| 319 | + pub mod auth; |
| 320 | pub mod delivery; |
| 321 | pub mod expand; |
| 322 | pub mod milter; |
| 323 | @@ -65,6 +66,7 @@ mod worker; |
| 324 | use smtp_proto::Response as SmtpResponse; |
| 325 | use transport::Response; |
| 326 | |
| 327 | + pub use auth::{AuthError, PlainAuth, PlainAuthFunc}; |
| 328 | pub use delivery::{Delivery, DeliveryError, DeliveryFunc}; |
| 329 | pub use expand::{Expansion, ExpansionError, ExpansionFunc}; |
| 330 | pub use milter::{Milter, MilterError, MilterFunc}; |
| 331 | diff --git a/maitred/src/session.rs b/maitred/src/session.rs |
| 332 | index 75b48c8..00e2d53 100644 |
| 333 | --- a/maitred/src/session.rs |
| 334 | +++ b/maitred/src/session.rs |
| 335 | @@ -10,6 +10,7 @@ use mail_parser::{Message, MessageParser}; |
| 336 | use smtp_proto::{EhloResponse, Request, Response as SmtpResponse}; |
| 337 | use url::Host; |
| 338 | |
| 339 | + use crate::auth::{AuthData, PlainAuth}; |
| 340 | use crate::expand::Expansion; |
| 341 | use crate::smtp_response; |
| 342 | use crate::transport::Response; |
| 343 | @@ -68,6 +69,7 @@ pub struct SessionOptions { |
| 344 | pub greeting: String, |
| 345 | pub list_expansion: Option<Arc<dyn Expansion>>, |
| 346 | pub verification: Option<Arc<dyn Verify>>, |
| 347 | + pub plain_auth: Option<Arc<dyn PlainAuth>>, |
| 348 | } |
| 349 | |
| 350 | impl Default for SessionOptions { |
| 351 | @@ -80,6 +82,7 @@ impl Default for SessionOptions { |
| 352 | greeting: DEFAULT_GREETING.to_string(), |
| 353 | list_expansion: None, |
| 354 | verification: None, |
| 355 | + plain_auth: None, |
| 356 | } |
| 357 | } |
| 358 | } |
| 359 | @@ -120,6 +123,15 @@ impl SessionOptions { |
| 360 | self.verification = Some(Arc::new(verification)); |
| 361 | self |
| 362 | } |
| 363 | + |
| 364 | + pub fn plain_auth<T>(mut self, plain_auth: T) -> Self |
| 365 | + where |
| 366 | + T: crate::auth::PlainAuth + 'static, |
| 367 | + { |
| 368 | + self.capabilities |= smtp_proto::EXT_AUTH; |
| 369 | + self.plain_auth = Some(Arc::new(plain_auth)); |
| 370 | + self |
| 371 | + } |
| 372 | } |
| 373 | |
| 374 | /// Stateful connection that coresponds to a single SMTP session. |
| 375 | @@ -132,10 +144,10 @@ pub(crate) struct Session { |
| 376 | /// rcpt address |
| 377 | pub rcpt_to: Option<Vec<EmailAddress>>, |
| 378 | pub hostname: Option<Host>, |
| 379 | - /// If an active data transfer is taking place |
| 380 | + // If an active data transfer is taking place |
| 381 | data_transfer: Option<DataTransfer>, |
| 382 | initialized: Option<Mode>, |
| 383 | - |
| 384 | + auth_initialized: bool, |
| 385 | // session options |
| 386 | opts: Rc<SessionOptions>, |
| 387 | } |
| 388 | @@ -278,6 +290,9 @@ impl Session { |
| 389 | let mut resp = EhloResponse::new(format!("Hello {}", host)); |
| 390 | resp.capabilities = self.opts.capabilities; |
| 391 | resp.size = self.opts.maximum_size as usize; |
| 392 | + if self.opts.plain_auth.is_some() { |
| 393 | + resp.auth_mechanisms = smtp_proto::AUTH_PLAIN; |
| 394 | + } |
| 395 | Ok(vec![Response::Ehlo(resp)]) |
| 396 | } |
| 397 | Request::Lhlo { host } => { |
| 398 | @@ -348,10 +363,36 @@ impl Session { |
| 399 | "Starting BDAT data transfer".to_string() |
| 400 | )]) |
| 401 | } |
| 402 | + // After an AUTH command has been successfully completed, no more |
| 403 | + // AUTH commands may be issued in the same session. After a |
| 404 | + // successful AUTH command completes, a server MUST reject any |
| 405 | + // further AUTH commands with a 503 reply. |
| 406 | Request::Auth { |
| 407 | mechanism, |
| 408 | initial_response, |
| 409 | - } => todo!(), |
| 410 | + } => { |
| 411 | + if let Some(auth_fn) = &self.opts.plain_auth { |
| 412 | + if *mechanism != smtp_proto::AUTH_PLAIN { |
| 413 | + // only plain auth is supported |
| 414 | + return Err(smtp_response!(504, 5, 5, 4, "Auth Not Supported")); |
| 415 | + } |
| 416 | + let auth_data = |
| 417 | + AuthData::try_from(initial_response.as_str()).map_err(|e| e.into())?; |
| 418 | + |
| 419 | + auth_fn |
| 420 | + .authenticate(&auth_data.authcid(), &auth_data.authzid(), &auth_data.passwd()) |
| 421 | + .await |
| 422 | + .map_err(|e| e.into())?; |
| 423 | + |
| 424 | + tracing::info!("Successfully authenticated"); |
| 425 | + |
| 426 | + self.auth_initialized = true; |
| 427 | + |
| 428 | + Ok(vec![smtp_response!(250, 0, 0, 0, "OK")]) |
| 429 | + } else { |
| 430 | + Err(smtp_response!(504, 5, 5, 4, "Auth Not Supported")) |
| 431 | + } |
| 432 | + } |
| 433 | Request::Noop { value: _ } => { |
| 434 | self.check_initialized()?; |
| 435 | Ok(vec![smtp_response!(250, 0, 0, 0, "OK".to_string())]) |
| 436 | @@ -552,8 +593,11 @@ mod test { |
| 437 | )), |
| 438 | }]; |
| 439 | let session = Mutex::new( |
| 440 | - Session::default() |
| 441 | - .with_options(SessionOptions::default().our_hostname(EXAMPLE_HOSTNAME).into()), |
| 442 | + Session::default().with_options( |
| 443 | + SessionOptions::default() |
| 444 | + .our_hostname(EXAMPLE_HOSTNAME) |
| 445 | + .into(), |
| 446 | + ), |
| 447 | ); |
| 448 | process_all(&session, requests).await; |
| 449 | } |
| 450 | @@ -812,8 +856,11 @@ transport rather than the session. |
| 451 | }, |
| 452 | ]; |
| 453 | let session = Mutex::new( |
| 454 | - Session::default() |
| 455 | - .with_options(SessionOptions::default().our_hostname(EXAMPLE_HOSTNAME).into()), |
| 456 | + Session::default().with_options( |
| 457 | + SessionOptions::default() |
| 458 | + .our_hostname(EXAMPLE_HOSTNAME) |
| 459 | + .into(), |
| 460 | + ), |
| 461 | ); |
| 462 | process_all(&session, requests).await; |
| 463 | let session = session.lock().await; |
| 464 | diff --git a/scripts/swaks_test_auth.sh b/scripts/swaks_test_auth.sh |
| 465 | new file mode 100755 |
| 466 | index 0000000..a58b874 |
| 467 | --- /dev/null |
| 468 | +++ b/scripts/swaks_test_auth.sh |
| 469 | @@ -0,0 +1,8 @@ |
| 470 | + #!/bin/sh |
| 471 | + |
| 472 | + # Uses swaks: https://www.jetmore.org/john/code/swaks/ to do some basic SMTP |
| 473 | + # verification. Make sure you install the tool first! |
| 474 | + |
| 475 | + printf "Subject: Hello\nWorld\n" | swaks --to hello@example.com --auth PLAIN \ |
| 476 | + --auth-user hello --auth-password world --auth-plaintext --server localhost:2525 \ |
| 477 | + --pipeline --data - |